SLOPSHOPPER

usage-bar

Desktop app only. A configurable status row at the bottom of the band above the prompt: 5-hour and weekly plan limits, prompt-cache time left, and optional…

newbandtoasttimer
v0.1.0MITupdated 2026-10-20zgmengde/usage-bar
A shopper browsing a rack in a slop shop
README

usage-bar

English | 简体中文

A mod for the Claude desktop app that draws a status row just above the prompt in the Code tab: your 5-hour and weekly plan limits, how long the prompt cache stays warm, and context use. A second row shows how the session is running: output speed, time to first token, cache hit rate and failed requests.

For now it supports the desktop app only. Claude Code in a terminal gets no row and no limit notifications.

<img alt="usage-bar in three states: healthy, getting tight, and near the limit" src="docs/preview-en-light.png">

Each number takes the color of its state, so you can read the row at a glance: green is fine, Claude orange is worth a look, red needs attention.

Install

In the Claude desktop app, open a conversation in the Code tab, then:

  1. Click + next to the prompt box, then Plugins > Manage plugins.
  2. Click Add > Add marketplace > Add from a repository.
  3. Enter zgmengde/usage-bar and click Sync.
  4. Find usage-bar in the plugin list and add it.

Or install it from a shell. The Code tab loads plugins installed at the user scope:

claude plugin install usage-bar --marketplace zgmengde/usage-bar

The row appears in your next conversation, or after /reload-plugins in an open one.

What it shows

First row: usage

ItemWhat it meansColor
ContextHow full the context window is, measured against the auto-compact point (the same number /context shows). Off by default, since the desktop app already shows it.Follows the percentage
5hShare of the 5-hour plan limit used, and the time until it resets.Follows the percentage
WeekShare of the weekly plan limit used, and the time until it resets.Follows the percentage
CacheHow long the main conversation's prompt cache stays warm: Live while a turn runs, a countdown after the last turn ends, Cold once it expires.Green while at least half the TTL is left, orange down to 10%, red below that; blue when cold
ExtraAny other limit window the API reports, such as a spend limit. Shown only once it passes conditional_threshold.Follows the percentage
CostThe session's estimated API cost. Off by default. On a subscription this is an estimate, not what you pay.—

Second row: performance

ItemWhat it meansGreenOrangeRed
SpeedOutput tokens per second for this session's main thread.≥ 9070–89< 70
TTFTAverage seconds from sending a main-thread request until its response starts.< 3 s3–5 s> 5 s
FailedRequests this session, subagents included, that errored or ended without a response. Requests you interrupt do not count.0—> 0
Cache hitShare of the latest main-thread request's input read from the prompt cache. Marked Write when that request wrote more to the cache than it read, which is normal for a first request or one after the cache expired.≥ 90%85–89%< 85%

The second row appears after the session's first request. A — means no data yet; for Cache hit, it means the request neither read nor wrote the cache. Colors are judged on the number as shown, so 90% is green even when the exact value is 89.6%.

The two rows line up column by column. With show_context on, that is Context above Speed, 5h above TTFT, Week above Failed, and Cache above Cache hit. When the window is too narrow, the bars get shorter first; only when they reach their minimum do the rows fall back to a packed layout.

How the performance numbers are computed

Speed and time to first token are session totals, summed the way DeepSeek Harness does it in its session stats:

  • TTFT (time to first token) = total time from each request's start to its first streamed chunk, divided by the number of requests.
  • speed = total output tokens divided by total time from each first chunk to the end of its response.

The app does not stream thinking text to plugins, but output_tokens includes thinking tokens. So the first chunk counts any chunk, including the engine's own envelope events; counting only visible text would give speeds of over ten thousand tokens per second. Totals smooth out a single odd request, so there is no outlier filter.

The hit rate is cache_read / (input + cache_read + cache_creation) for the latest main-thread request, rounded like DeepSeek Harness: to a whole percent, unless that would show 100% for a request that did not fully hit; then it adds decimal places until it no longer does (99.96%, 99.999%), up to six.

Settings

Change them in the plugin's settings screen, or in ~/.claude/settings.json under pluginConfigs["usage-bar@usage-bar"].options.

KeyDefaultWhat it does
show_five_hourtrueShow the 5-hour limit.
show_weeklytrueShow the weekly limit.
show_cachetrueShow the prompt-cache countdown.
show_other_limitstrueShow other limit windows once they pass conditional_threshold.
show_contextfalseShow context use.
show_costfalseShow the session's estimated cost.
show_resettrueShow the time left until each limit resets.
show_speedtrueSecond row: output speed.
show_first_tokentrueSecond row: time to first token.
show_cache_hittrueSecond row: cache hit rate.
show_failurestrueSecond row: failed requests.
conditional_threshold50Percent at which other limits start to show.
layoutautoauto gives usage and performance a row each, and wraps a row that is too narrow. one-line puts everything on one row. two-lines splits the items into two rows when only one group is shown.
barditherdither pixel bars, solid filled bars, none numbers only.
colorby-percentby-percent blends through the color stops; threshold changes color at 80% and 95%; mono uses one color.
color_stops0:#46a758,60:#d97757,100:#e5484dpercent:#hex pairs; colors blend by hue between stops.
iconsonShow an icon before each item.
cache_ttl1hLength of the cache countdown. Match it to your prompt-cache TTL (5m or 1h).
notify_at80,95Percents of the 5-hour or weekly limit that raise a notification, once per percent per window. Empty turns it off.
languageautoLabel language: en, zh, or auto to follow LC_ALL or LANG. The desktop app usually has no LANG, so set zh there if you want Chinese labels.

The color thresholds of the second row are fixed for now. Green, orange and red are the first, middle and last of color_stops, so changing the stops or choosing mono changes them too.

Keeping the row next to the prompt

usage-bar asks the other mods for their part of the band first, puts it above, and draws its own rows last, right on top of the prompt. If another mod does the same, the one deeper in the hook chain loses. To make usage-bar the outermost, list it first in prependPlugins in ~/.claude/settings.json:

{
  "prependPlugins": ["usage-bar@usage-bar"]
}

The app reads this key from managed settings; on a machine with no managed settings it also reads it from your user settings, for plugins you installed yourself.

Privacy

  • No network access: the mod makes no requests of its own.
  • It runs no commands and reads or writes no files of its own. It reads only what the app hands every plugin: the session's usage, rate limits, model, and the LANG and LC_ALL variables.
  • To time requests it sits in the response stream: it notes when chunks arrive and reads each response's token usage and stop reason. It does not read or keep the content, and passes every chunk on unchanged.
  • It keeps a few values in its plugin store, ~/.claude/plugins/store/usage-bar_*.json, so the row survives a restart:
  • the latest limit percentages and reset times (shared by all sessions, since limits belong to the account);
  • the end time of each session's latest turn and its performance totals, keyed by session id, up to 20 sessions;
  • which limit notifications it already sent, up to 50.
  • /clear deletes the current session's entries. Nothing else leaves your machine.

claude plugin validate lists every engine call the mod makes, if you want to check this yourself.

Limitations

  • The row appears once a conversation is running. A new conversation starts when you send its first message, so an empty new conversation shows no row.
  • Limit percentages come with model responses. A fresh process shows the values saved from the last response until the next one arrives, and they can read about a point lower than the official usage page.
  • Plugins do not get the band's pixel width, so the layout works from an estimate (bodyColumns × 8 px). The row is centered so the margins stay even, but a text can be off by a few pixels.
  • Desktop app only, for now. Other surfaces (Claude Code in a terminal, VS Code, mobile) get no row and no limit notifications; limit values saved by a session there are shared, so the desktop row can still use them.

Development

claude plugin validate .   # manifest, hooks and every engine call
claude plugin test .       # unit and render tests

To try a change, run /reload-plugins in a session that has the mod installed from this folder. To type-check, start one session with claude --plugin-dir . so the engine writes the claude-code types to .claude-plugin/types/, then run npx -p typescript tsc -p ..

License

MIT

Source 4 files
hooks/register.tsx 362 lines
1// usage-bar:在桌面版输入框上方横条的最后一行画额度、缓存等状态。
2// 目前只支持桌面版:终端等其他界面不画横条,也不发额度提醒。
3// 只读会话自己的用量数据,不联网、不跑命令。$.store 里只存 4 样:提醒过的额度、
4// 最近一次的额度、每个会话最近一轮的结束时间、每个会话的请求性能,
5// 后三样用来在重启后先显示上次的值。
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer, TurnStepChunk, TurnStepResult } from 'claude-code'
8
9import { buildItems, layoutRows, nextPerf, parseConfig, resolveLang } from './model'
10import type { Config, Lang, Perf, Snapshot, Step } from './model'
11import { ROW_H, rowAlt, rowSvg } from './svg'
12
13const lastTurnAt = atom({ plugin: 'usage-bar', key: 'lastTurnAt' } as const, null)
14const tick = atom({ plugin: 'usage-bar', key: 'tick' } as const, 0)
15const perfAtom = atom({ plugin: 'usage-bar', key: 'perf' } as const, null)
16
17const WARNED_KEY = 'warned'
18const LIMITS_KEY = 'limits'
19const TURNS_KEY = 'turns'
20const PERF_KEY = 'perf'
21// 每个会话一条,只留最近的这么多条
22const TURNS_KEEP = 20
23
24// 桌面版每个 bodyColumns 约 8 CSS px
25const PX_PER_COLUMN = 8
26
27// 热重载和改配置都会重跑 register,但不会再触发 session.start,
28// 所以计时器、语言和压缩窗口放在这里,由每个钩子按需准备
29type Runtime = {
30  cfg: Config
31  lang: Lang | null
32  minute: Timer | null
33  expiry: Timer | null
34  // 性能数据延迟写进 $.store 的计时器
35  persist: Timer | null
36  compact: { model: string; tokens: number } | null
37  // 横条在桌面版画过:只支持桌面版,提醒也只在这之后发
38  onDesktop: boolean
39}
40
41const bump = ($: EngineInterface) => void update($, tick, n => n + 1).catch(() => undefined)
42
43// 自动压缩的触发点(/context 的 rawMaxTokens)。它可能比模型窗口小,
44// 例如 settings 里设了 autoCompactWindow;按模型缓存,换模型时重新取
45async function compactWindow($: EngineInterface, rt: Runtime): Promise<number | null> {
46  const model = await $.session.model()
47  if (rt.compact?.model === model) return rt.compact.tokens
48  const usage = await $.session.usage({ breakdown: 'summary' })
49  const tokens = usage.context.breakdown?.rawMaxTokens ?? null
50  // 会话还没绑定时拿不到,下次再取
51  if (tokens) rt.compact = { model, tokens }
52  return tokens
53}
54
55type Limits = Snapshot['limits']
56type Turns = Record<string, number>
57
58const isLimits = (v: unknown): v is Limits =>
59  Array.isArray(v) && v.every(l => l && typeof l.kind === 'string' && typeof l.percentUsed === 'number')
60
61const asTurns = (v: unknown): Turns =>
62  v && typeof v === 'object' && !Array.isArray(v)
63    ? Object.fromEntries(Object.entries(v as Record<string, unknown>).filter((kv): kv is [string, number] => typeof kv[1] === 'number'))
64    : {}
65
66// 额度只跟着模型响应来,新进程第一次响应之前是空的;这时先用上次存下的值。
67// 额度是整个账号的,所以不分会话
68async function savedLimits($: EngineInterface): Promise<Limits> {
69  const stored = (await $.store.get(LIMITS_KEY).catch(() => null)) as { limits?: unknown } | null
70  return isLimits(stored?.limits) ? stored.limits : []
71}
72
73async function saveLimits($: EngineInterface, limits: Limits) {
74  if (limits.length > 0) await $.store.set(LIMITS_KEY, { limits })
75}
76
77// $.state 在进程重启后就没了,最近一轮的结束时间另存一份,按会话分开
78async function savedTurn($: EngineInterface): Promise<number | null> {
79  const id = await $.session.id()
80  return asTurns(await $.store.get(TURNS_KEY).catch(() => null))[id] ?? null
81}
82
83async function saveTurn($: EngineInterface, sessionId: string, at: number | null) {
84  const turns = asTurns(await $.store.get(TURNS_KEY).catch(() => null))
85  delete turns[sessionId]
86  if (at !== null) turns[sessionId] = at
87  const kept = Object.entries(turns).sort((a, b) => b[1] - a[1]).slice(0, TURNS_KEEP)
88  await $.store.set(TURNS_KEY, Object.fromEntries(kept))
89}
90
91type PerfEntry = { at: number; perf: Perf }
92type PerfMap = Record<string, PerfEntry>
93
94const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v)
95const numOrNull = (v: unknown) => v === null || num(v)
96const nonNeg = (v: unknown) => num(v) && (v as number) >= 0
97const isPerf = (v: unknown): v is Perf => {
98  const p = v as Perf | null
99  return !!p && typeof p === 'object' && nonNeg(p.ttftMs) && nonNeg(p.ttftSteps) && nonNeg(p.decodeMs) && nonNeg(p.decodeTokens) &&
100    (p.hitPercent === null || (nonNeg(p.hitPercent) && p.hitPercent <= 100)) && typeof p.hitWrite === 'boolean' && nonNeg(p.failures)
101}
102const asPerfMap = (v: unknown): PerfMap =>
103  v && typeof v === 'object' && !Array.isArray(v)
104    ? Object.fromEntries(
105        Object.entries(v as Record<string, PerfEntry>).filter(([, e]) => e && typeof e === 'object' && num(e.at) && isPerf(e.perf)),
106      )
107    : {}
108
109async function savedPerf($: EngineInterface): Promise<Perf | null> {
110  const id = await $.session.id()
111  return asPerfMap(await $.store.get(PERF_KEY).catch(() => null))[id]?.perf ?? null
112}
113
114async function savePerf($: EngineInterface, sessionId: string, perf: Perf | null) {
115  const map = asPerfMap(await $.store.get(PERF_KEY).catch(() => null))
116  delete map[sessionId]
117  if (perf) map[sessionId] = { at: await $.clock.now(), perf }
118  const kept = Object.entries(map).sort((a, b) => b[1].at - a[1].at).slice(0, TURNS_KEEP)
119  await $.store.set(PERF_KEY, Object.fromEntries(kept))
120}
121
122async function flushPerf($: EngineInterface) {
123  const perf = await read($, perfAtom)
124  if (perf) await savePerf($, await $.session.id(), perf)
125}
126
127// 一次请求结束后并进本会话的性能数据。写 $.store 交给 1 秒后的计时器:
128// 不拖慢这次请求,几个子代理同时结束时也只写一次最新值
129async function recordStep($: EngineInterface, rt: Runtime, step: Step) {
130  // reload 后 $.state 还在,里面可能是旧版本的格式,不合格的当成没有
131  const live = await read($, perfAtom)
132  const saved = isPerf(live) ? null : await savedPerf($).catch(() => null)
133  await update($, perfAtom, prev => nextPerf(isPerf(prev) ? prev : saved, step))
134  rt.persist?.cancel()
135  rt.persist = $.clock.after(1_000, () => void flushPerf($).catch(() => undefined))
136}
137
138async function snapshot($: EngineInterface, rt: Runtime, isWorking: boolean): Promise<Snapshot> {
139  const [usage, now, live, livePerf] = await Promise.all([$.session.usage(), $.clock.now(), read($, lastTurnAt), read($, perfAtom)])
140  const last = live ?? (await savedTurn($).catch(() => null))
141  const liveLimits: Limits = usage.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
142  const limits = liveLimits.length > 0 ? liveLimits : await savedLimits($)
143  // 上下文占比按自动压缩的触发点算,和 /context 一致;拿不到时退回按模型窗口算
144  let contextPercent = usage.context.percent ?? null
145  if (rt.cfg.showContext && usage.context.tokens !== undefined) {
146    const max = await compactWindow($, rt).catch(() => null)
147    if (max) contextPercent = Math.min(100, Math.round((usage.context.tokens / max) * 100))
148  }
149  return {
150    now,
151    isWorking,
152    limits,
153    contextPercent,
154    costUsd: usage.cost?.usd ?? null,
155    lastTurnAt: last,
156    perf: isPerf(livePerf) ? livePerf : await savedPerf($).catch(() => null),
157  }
158}
159
160const NOTIFY_NAME: Record<Lang, Record<string, string>> = {
161  en: { five_hour: '5-hour', seven_day: 'Weekly' },
162  zh: { five_hour: '5 小时', seven_day: '每周' },
163}
164const NOTIFY_TEXT: Record<Lang, (name: string, percent: number) => string> = {
165  en: (name, percent) => `${name} limit at ${percent}%`,
166  zh: (name, percent) => `${name}额度已用 ${percent}%`,
167}
168
169async function notifyLimits($: EngineInterface, cfg: Config, lang: Lang, limits: Snapshot['limits']) {
170  if (cfg.notifyAt.length === 0) return
171  const stored = await $.store.get(WARNED_KEY)
172  const seen = Array.isArray(stored) ? stored.filter((k): k is string => typeof k === 'string') : []
173  const due: { key: string; text: string }[] = []
174  for (const l of limits) {
175    if (l.kind !== 'five_hour' && l.kind !== 'seven_day') continue
176    const at = cfg.notifyAt.find(t => l.percentUsed >= t)
177    if (at === undefined) continue
178    // 同一个窗口、同一个阈值只提醒一次。重置时间按 5 分钟取整,
179    // 免得同一窗口里时间戳的细微抖动被当成新窗口;窗口重置后会重新提醒
180    const resetsAt = l.resetsAt ? Math.round(Date.parse(l.resetsAt) / 300_000) : 'none'
181    const key = `${l.kind}@${at}@${resetsAt}`
182    if (seen.includes(key)) continue
183    due.push({ key, text: NOTIFY_TEXT[lang](NOTIFY_NAME[lang][l.kind] ?? l.kind, Math.round(l.percentUsed)) })
184  }
185  if (due.length === 0) return
186  // 先记下再发,两个会话同时测到时少发一次重复提醒
187  await $.store.set(WARNED_KEY, [...seen, ...due.map(d => d.key)].slice(-50))
188  for (const d of due) {
189    const sent = await $.ui.notify(d.text, { title: 'usage-bar' }).catch(() => null)
190    // 用户关掉了通知就不打扰;桌面版没有系统通知通道,改用停留久一点的 toast
191    if (sent && !sent.isSent && sent.reason !== 'disabled') $.ui.toast(d.text, { timeoutMs: 15_000 })
192  }
193}
194
195
196async function resolveLangOnce($: EngineInterface, rt: Runtime): Promise<Lang> {
197  if (!rt.lang) rt.lang = resolveLang(rt.cfg.language, (await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
198  return rt.lang
199}
200
201async function ensure($: EngineInterface, rt: Runtime): Promise<Lang> {
202  // 倒计时按分钟走,所以每分钟让横条重画一次
203  if (!rt.minute) rt.minute = $.clock.every(60_000, () => bump($))
204  return resolveLangOnce($, rt)
205}
206
207// 到期那一刻再重画一次,不用等下一分钟才变成“已冷”
208function scheduleExpiry($: EngineInterface, rt: Runtime) {
209  rt.expiry?.cancel()
210  rt.expiry = $.clock.after(rt.cfg.cacheTtlMs + 1_000, () => bump($))
211}
212
213function stop(rt: Runtime) {
214  rt.minute?.cancel()
215  rt.expiry?.cancel()
216  rt.minute = null
217  rt.expiry = null
218}
219
220export const register: Register = (on, options) => {
221  const cfg = parseConfig(options as Record<string, unknown>)
222  const rt: Runtime = { cfg, lang: cfg.language === 'auto' ? null : cfg.language, minute: null, expiry: null, persist: null, compact: null, onDesktop: false }
223
224  on('session.start', async ($, e, next) => {
225    const started = await next(e)
226    stop(rt)
227    // 无头运行没有输入框,用不着重画
228    if (e.isInteractive) await ensure($, rt)
229    return started
230  })
231
232  // 会话结束时清掉内存里的状态。只有 /clear 才删存下的记录:
233  // 正常退出或 resume 后,下次打开同一会话还要显示这些值
234  on('session.end', async ($, e, next) => {
235    const ended = await next(e)
236    stop(rt)
237    await update($, lastTurnAt, () => null).catch(() => undefined)
238    await update($, perfAtom, () => null).catch(() => undefined)
239    if (e.reason === 'clear') {
240      rt.persist?.cancel()
241      await saveTurn($, e.sessionId, null).catch(() => undefined)
242      await savePerf($, e.sessionId, null).catch(() => undefined)
243    }
244    return ended
245  })
246
247  // 主对话每轮结束时记下时间:缓存从这一刻开始计时。
248  // 报错的轮次可能没有成功请求,不刷新缓存
249  on('turn.complete', async ($, e, next) => {
250    const done = await next(e)
251    if (!e.agentId && e.reason !== 'error') {
252      await ensure($, rt)
253      const now = await $.clock.now()
254      await update($, lastTurnAt, () => now)
255      await saveTurn($, await $.session.id(), now).catch(() => undefined)
256      scheduleExpiry($, rt)
257    }
258    return done
259  })
260
261  // 每次模型请求:记下首字时间和输出速度,结束后算命中率、数失败。
262  // 数据块原样往下传,这个钩子只计时
263  on('turn.step', async function* ($, e, next) {
264    const started = await $.clock.now()
265    let first: number | null = null
266    const stream = next(e)
267    // closed:引擎提前关掉了这个流,多半是用户打断
268    const done = async (result: TurnStepResult | null, closed = false) => {
269      const ended = await $.clock.now()
270      // 用户按 Esc 打断的请求不算失败
271      const failed = !closed && !next.signal.aborted && (result === null || result.stopReason === null)
272      await recordStep($, rt, {
273        isMain: !e.agentId,
274        failed,
275        firstTokenMs: first === null ? null : first - started,
276        streamMs: first === null ? null : ended - first,
277        usage: result?.usage ?? null,
278      }).catch(() => undefined)
279    }
280    let finished = false
281    try {
282      // 手动迭代:最后一次 next() 的返回值就是这次请求的结果
283      for (;;) {
284        let r: IteratorResult<TurnStepChunk, TurnStepResult>
285        try {
286          r = await stream.next()
287        } catch (err) {
288          finished = true
289          await done(null)
290          throw err
291        }
292        if (r.done) {
293          finished = true
294          await done(r.value)
295          return r.value
296        }
297        // 任何数据块都算,engine 块也算:思考内容不推给插件,但响应开头的信封会立刻到,
298        // 只认可见内容的话,首字会把整段思考时间算进去,速度的分母又只剩最后一小段
299        if (first === null) first = await $.clock.now().catch(() => null)
300        yield r.value
301      }
302    } finally {
303      // 引擎在 yield 处关掉了这个流:把关闭传给下层,这次请求只记首字
304      if (!finished) {
305        await stream.return(undefined as unknown as TurnStepResult).catch(() => undefined)
306        await done(null, true)
307      }
308    }
309  })
310
311  // 额度变化时重画,并检查要不要发提醒
312  on('session.measure', async ($, e, next) => {
313    const measured = await next(e)
314    const current = await ensure($, rt)
315    $.ui.invalidate('ui.render')
316    await saveLimits($, e.rateLimits).catch(() => undefined)
317    if (rt.onDesktop) await notifyLimits($, cfg, current, e.rateLimits).catch(() => undefined)
318    return measured
319  })
320
321  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
322    // 只支持桌面版,其他界面原样交给下一层
323    if (e.surface !== 'desktop') return next(e)
324    rt.onDesktop = true
325    if (e.props.hasSurvey) return next(e)
326    await read($, tick)
327    const lang = await resolveLangOnce($, rt)
328
329    const items = buildItems(cfg, lang, await snapshot($, rt, e.props.isWorking))
330    if (items.length === 0) return next(e)
331
332    // 先拿其他 mod 画的内容放在上面,自己排在最后一行,紧贴输入框。
333    // Claude Code 自己的占位节点不能嵌套,桌面版会丢掉嵌套了它的横条。
334    const drawn = await Promise.resolve(next(e)).catch(() => null)
335    const above = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
336
337    const ui = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Svg: any }
338    const { Box, Svg } = ui
339    // 元素表里没有 Svg 的桌面版画不了,交回下一层
340    if (!Svg) return next(e)
341    const columns = e.props.bodyColumns || 100
342    // 每列约 8px 是估算,再留出卡片内边距,避免撑破横条
343    const width = Math.max(240, Math.min(1600, columns * PX_PER_COLUMN - 32))
344
345    const rows = layoutRows(items, cfg, width)
346    // 插件拿不到横条的真实像素宽度,估算的 width 只用来排版。画布只取内容宽度、
347    // 不写 width,横条会把它居中,左右留白才一样;几行共用同一个内容宽度,列才对得上
348    const content = Math.max(...rows.map(r => rowSvg(r, cfg).width))
349    const ours = rows.map((row, i) => {
350      const svg = rowSvg(row, cfg, content, content)
351      return <Svg key={`row-${i}`} source={svg.source} alt={rowAlt(row)} height={ROW_H} />
352    })
353
354    return (
355      <Box flexDirection="column">
356        {above}
357        {ours}
358      </Box>
359    )
360  })
361}
362
hooks/model.ts 517 lines
1// 纯函数:解析配置、算颜色、格式化、排版。不碰 $,方便单测。
2
3export type Lang = 'en' | 'zh'
4export type Layout = 'auto' | 'one-line' | 'two-lines'
5export type BarStyle = 'dither' | 'solid' | 'none'
6export type ColorMode = 'by-percent' | 'threshold' | 'mono'
7export type IconName = 'clock' | 'calendar' | 'flame' | 'flake' | 'layers' | 'coin' | 'limit' | 'bolt' | 'hourglass' | 'target' | 'cross'
8
9export type Config = {
10  showFiveHour: boolean
11  showWeekly: boolean
12  showCache: boolean
13  showOtherLimits: boolean
14  showContext: boolean
15  showCost: boolean
16  showReset: boolean
17  showSpeed: boolean
18  showFirstToken: boolean
19  showCacheHit: boolean
20  showFailures: boolean
21  conditionalThreshold: number
22  layout: Layout
23  bar: BarStyle
24  color: ColorMode
25  stops: ColorStop[]
26  icons: boolean
27  cacheTtlMs: number
28  notifyAt: number[]
29  language: 'auto' | Lang
30}
31
32export type ColorStop = { at: number; hex: string }
33
34// 状态栏里的一项。percent 为空时不画进度条。
35// group 决定排在哪一行:usage 是额度类,perf 是请求性能类
36export type Item = {
37  key: string
38  group: 'usage' | 'perf'
39  icon: IconName
40  label: string
41  percent: number | null
42  value: string
43  detail: string
44  // 覆盖按百分比算出的颜色,例如缓存变冷时用蓝色;图标、进度条和数值都用它
45  color?: string
46  // 只给数值上色,图标保持灰色:第二行按阈值分好、中、差三档
47  valueColor?: string
48}
49
50export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
51
52// 本会话的请求性能。速度和首字按 dsh(DeepSeek Harness,github.com/deepseek-ai/deepseek-harness)
53// 的 session-stats 口径累计主线程请求:
54// 速度 = Σ 输出 token ÷ Σ(结束 − 首 token),首字 = Σ(首 token − 开始)÷ 步数。
55// 命中率取主线程最近一次请求,失败次数累计全部请求(含子代理)
56export type Perf = {
57  ttftMs: number
58  ttftSteps: number
59  decodeMs: number
60  decodeTokens: number
61  hitPercent: number | null
62  // 这次写缓存比读缓存多:会话第一次请求或缓存过期后都会这样,属于正常情况
63  hitWrite: boolean
64  failures: number
65}
66
67export type Snapshot = {
68  now: number
69  isWorking: boolean
70  limits: Limit[]
71  contextPercent: number | null
72  costUsd: number | null
73  lastTurnAt: number | null
74  // 本会话还没发过请求时为空,这时整行性能项都不画
75  perf: Perf | null
76}
77
78export type StepUsage = {
79  input_tokens: number
80  output_tokens: number
81  cache_read_input_tokens: number
82  cache_creation_input_tokens: number
83}
84
85// 一次模型请求测到的数据
86export type Step = {
87  isMain: boolean
88  failed: boolean
89  // 请求发出到第一个数据块(模型开始回应)的毫秒数;什么都没收到时为空
90  firstTokenMs: number | null
91  // 第一个数据块到请求结束的毫秒数。输出 token 含看不到的思考,所以按整段生成时长算速度
92  streamMs: number | null
93  usage: StepUsage | null
94}
95
96export function nextPerf(prev: Perf | null, step: Step): Perf {
97  const base: Perf = prev ?? { ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, hitPercent: null, hitWrite: false, failures: 0 }
98  const out: Perf = { ...base, failures: base.failures + (step.failed ? 1 : 0) }
99  // 子代理在后台跑,它的延迟不影响你等多久,所以只记它的失败
100  if (!step.isMain || step.failed) return out
101  const u = step.usage
102  // 和 dsh 一样:有首 token 才计首字;再有用量才计解码时长和 token。单次异常靠累计摊平,不另设阈值
103  if (step.firstTokenMs !== null) {
104    out.ttftMs += step.firstTokenMs
105    out.ttftSteps += 1
106    if (u && step.streamMs !== null) {
107      out.decodeMs += step.streamMs
108      out.decodeTokens += u.output_tokens
109    }
110  }
111  if (u) {
112    // input_tokens 只算没走缓存的部分,所以分母要把三项加起来
113    const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
114    // 既没读也没写缓存:多半是不支持 prompt cache 的网关,显示成 —,别当成 0% 报警
115    if (u.cache_read_input_tokens + u.cache_creation_input_tokens === 0) {
116      out.hitPercent = null
117      out.hitWrite = false
118    } else if (total > 0) {
119      out.hitPercent = (u.cache_read_input_tokens / total) * 100
120      out.hitWrite = u.cache_creation_input_tokens > u.cache_read_input_tokens
121    }
122  }
123  return out
124}
125
126export const DEFAULT_STOPS = '0:#46a758,60:#d97757,100:#e5484d'
127
128const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : v === 'true' ? true : v === 'false' ? false : d)
129const pick = <T extends string>(v: unknown, allowed: readonly T[], d: T): T =>
130  typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : d
131
132export function parseStops(text: unknown): ColorStop[] {
133  const parse = (s: string) =>
134    s
135      .split(',')
136      .map(p => p.trim().match(/^(\d{1,3})\s*:\s*(#[0-9a-fA-F]{6})$/))
137      .filter((m): m is RegExpMatchArray => m !== null)
138      .map(m => ({ at: Math.min(100, Number(m[1])), hex: (m[2] ?? '').toLowerCase() }))
139      .sort((a, b) => a.at - b.at)
140  const stops = typeof text === 'string' ? parse(text) : []
141  return stops.length >= 2 ? stops : parse(DEFAULT_STOPS)
142}
143
144export function parseNotifyAt(text: unknown): number[] {
145  if (typeof text !== 'string') return [80, 95]
146  return [...new Set(text.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 100))].sort(
147    (a, b) => b - a,
148  )
149}
150
151export function parseConfig(o: Record<string, unknown>): Config {
152  const raw = o.conditional_threshold
153  const threshold = typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') ? Number(raw) : NaN
154  return {
155    showFiveHour: bool(o.show_five_hour, true),
156    showWeekly: bool(o.show_weekly, true),
157    showCache: bool(o.show_cache, true),
158    showOtherLimits: bool(o.show_other_limits, true),
159    showContext: bool(o.show_context, false),
160    showCost: bool(o.show_cost, false),
161    showReset: bool(o.show_reset, true),
162    showSpeed: bool(o.show_speed, true),
163    showFirstToken: bool(o.show_first_token, true),
164    showCacheHit: bool(o.show_cache_hit, true),
165    showFailures: bool(o.show_failures, true),
166    conditionalThreshold: Number.isFinite(threshold) ? Math.max(0, Math.min(100, threshold)) : 50,
167    layout: pick(o.layout, ['auto', 'one-line', 'two-lines'] as const, 'auto'),
168    bar: pick(o.bar, ['dither', 'solid', 'none'] as const, 'dither'),
169    color: pick(o.color, ['by-percent', 'threshold', 'mono'] as const, 'by-percent'),
170    stops: parseStops(o.color_stops),
171    icons: pick(o.icons, ['on', 'off'] as const, 'on') === 'on',
172    cacheTtlMs: pick(o.cache_ttl, ['1h', '5m'] as const, '1h') === '5m' ? 5 * 60_000 : 60 * 60_000,
173    notifyAt: parseNotifyAt(o.notify_at),
174    language: pick(o.language, ['auto', 'en', 'zh'] as const, 'auto'),
175  }
176}
177
178export function resolveLang(setting: Config['language'], envLang: string | undefined): Lang {
179  if (setting !== 'auto') return setting
180  return /^zh/i.test(envLang ?? '') ? 'zh' : 'en'
181}
182
183const LABELS: Record<Lang, Record<string, string>> = {
184  en: {
185    five_hour: '5h', seven_day: 'Week', spend_limit: 'Extra', cache: 'Cache', context: 'Context', cost: 'Cost', cold: 'Cold', live: 'Live',
186    speed: 'Speed', first_token: 'TTFT', cache_hit: 'Cache hit', failures: 'Failed', write: 'Write',
187  },
188  zh: {
189    five_hour: '5h', seven_day: '周', spend_limit: '超额', cache: '缓存', context: '上下文', cost: '费用', cold: '已冷', live: '活跃',
190    speed: '速度', first_token: '首字', cache_hit: '命中', failures: '失败', write: '写入',
191  },
192}
193
194// 英文标签首字母大写(Context、Cache hit),和桌面版界面文字一致;表里没有的额度类型按同样规则显示
195export const label = (lang: Lang, key: string) => {
196  const raw = key.replace(/_/g, ' ')
197  return LABELS[lang][key] ?? raw.charAt(0).toUpperCase() + raw.slice(1)
198}
199
200// ---- 颜色:按色相在锚点之间插值,中间经过黄色,不会发灰 ----
201
202type Hsl = [number, number, number]
203
204function hexToHsl(hex: string): Hsl {
205  const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255) as [number, number, number]
206  const max = Math.max(r, g, b)
207  const min = Math.min(r, g, b)
208  const l = (max + min) / 2
209  if (max === min) return [0, 0, l]
210  const d = max - min
211  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
212  const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) * 60 : max === g ? ((b - r) / d + 2) * 60 : ((r - g) / d + 4) * 60
213  return [h, s, l]
214}
215
216function hslToHex([h, s, l]: Hsl): string {
217  const hue = ((h % 360) + 360) % 360
218  const c = (1 - Math.abs(2 * l - 1)) * s
219  const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
220  const m = l - c / 2
221  const [r, g, b] =
222    hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x]
223  return '#' + [r, g, b].map(v => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')
224}
225
226// 两个色相之间走较短的那一圈,所以 14°(Claude 橙)到 358°(红)不会绕过绿和蓝
227function lerpHue(a: number, b: number, t: number) {
228  let d = b - a
229  if (d > 180) d -= 360
230  if (d < -180) d += 360
231  return a + d * t
232}
233
234// stops 至少有两个锚点:parseStops 保证了这一点
235export function blend(stops: ColorStop[], percent: number): string {
236  const p = Math.max(0, Math.min(100, percent))
237  const first = stops[0]!
238  if (p <= first.at) return first.hex
239  for (let i = 1; i < stops.length; i++) {
240    const a = stops[i - 1]!
241    const b = stops[i]!
242    if (p <= b.at) {
243      const t = b.at === a.at ? 1 : (p - a.at) / (b.at - a.at)
244      const [ha, sa, la] = hexToHsl(a.hex)
245      const [hb, sb, lb] = hexToHsl(b.hex)
246      return hslToHex([lerpHue(ha, hb, t), sa + (sb - sa) * t, la + (lb - la) * t])
247    }
248  }
249  return stops[stops.length - 1]!.hex
250}
251
252export const MONO = '#5b8def'
253export const COLD = '#6cb6ff'
254
255export type Level = 'good' | 'warn' | 'bad'
256
257// 三档颜色取锚点的头、中、尾,默认是绿、Claude 橙、红;mono 模式下都用同一个颜色
258export function levelColor(cfg: Config, level: Level): string {
259  if (cfg.color === 'mono') return MONO
260  const stops = cfg.stops
261  if (level === 'good') return stops[0]!.hex
262  if (level === 'bad') return stops[stops.length - 1]!.hex
263  return stops.length > 2 ? stops[Math.floor(stops.length / 2)]!.hex : blend(stops, 50)
264}
265
266// 第二行的阈值(按显示出来的数判断,免得显示 90 却是橙色):
267// 速度 ≥90 好、70–89 中、<70 差;首字 <3s 好、3–5s 中、>5s 差;命中 ≥90% 好、85%–90% 中(不含 90)、<85% 差
268export const speedLevel = (tps: number): Level => (tps >= 90 ? 'good' : tps >= 70 ? 'warn' : 'bad')
269export const firstTokenLevel = (s: number): Level => (s < 3 ? 'good' : s <= 5 ? 'warn' : 'bad')
270export const hitLevel = (percent: number): Level => (percent >= 90 ? 'good' : percent >= 85 ? 'warn' : 'bad')
271// 缓存倒计时按剩余占 TTL 的比例:≥50% 好、10%–50% 中、<10% 差,1h 和 5m 两种 TTL 都适用
272export const cacheLevel = (leftRatio: number): Level => (leftRatio >= 0.5 ? 'good' : leftRatio >= 0.1 ? 'warn' : 'bad')
273
274export function colorFor(cfg: Config, percent: number): string {
275  if (cfg.color === 'mono') return MONO
276  if (cfg.color === 'threshold') {
277    const first = cfg.stops[0]!.hex
278    const last = cfg.stops[cfg.stops.length - 1]!.hex
279    const middle = cfg.stops.length > 2 ? cfg.stops[Math.floor(cfg.stops.length / 2)]!.hex : blend(cfg.stops, 50)
280    return percent >= 95 ? last : percent >= 80 ? middle : first
281  }
282  return blend(cfg.stops, percent)
283}
284
285// ---- 格式化 ----
286
287export function duration(ms: number): string {
288  const minutes = Math.max(0, Math.round(ms / 60_000))
289  if (minutes < 60) return `${minutes}m`
290  if (minutes < 1440) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
291  return `${Math.floor(minutes / 1440)}d${Math.floor((minutes % 1440) / 60)}h`
292}
293
294const pct = (n: number) => `${Math.round(n)}%`
295const DASH = '—'
296
297// 命中率的显示,照 dsh 的 formatCacheHitPercent:先四舍五入到整数;
298// 没全中却会进位成 100 时,逐位加小数,直到和 100 区分开(99.5、99.96)
299export function hitText(percent: number): string {
300  if (percent >= 100) return '100%'
301  const whole = Math.round(percent)
302  if (whole < 100) return `${whole}%`
303  for (let places = 1; places <= 6; places++) {
304    const scale = 10 ** places
305    const rounded = Math.round(percent * scale) / scale
306    if (rounded < 100) return `${rounded.toFixed(places)}%`
307  }
308  return '99.999999%'
309}
310
311// ---- 从快照算出要画的项,顺序固定 ----
312
313export function buildItems(cfg: Config, lang: Lang, s: Snapshot): Item[] {
314  const items: Item[] = []
315  const limitItem = (l: Limit, icon: IconName): Item => {
316    const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
317    // 窗口已经重置、但还没收到新的响应:按 0% 显示,别让旧数字吓人
318    const used = Number.isFinite(resetsAt) && resetsAt <= s.now ? 0 : l.percentUsed
319    return {
320      key: l.kind,
321      icon,
322      label: label(lang, l.kind),
323      percent: used,
324      value: pct(used),
325      detail: cfg.showReset && Number.isFinite(resetsAt) && resetsAt > s.now ? duration(resetsAt - s.now) : '',
326      group: 'usage',
327    }
328  }
329
330  // 上下文放第一列;第一行第 4 列是缓存,第二行第 4 列是命中,上下对齐
331  if (cfg.showContext && s.contextPercent !== null) {
332    items.push({ key: 'context', icon: 'layers', label: label(lang, 'context'), percent: s.contextPercent, value: pct(s.contextPercent), detail: '', group: 'usage' })
333  }
334  const five = s.limits.find(l => l.kind === 'five_hour')
335  if (cfg.showFiveHour && five) items.push(limitItem(five, 'clock'))
336  const week = s.limits.find(l => l.kind === 'seven_day')
337  if (cfg.showWeekly && week) items.push(limitItem(week, 'calendar'))
338
339  // 新会话第一轮还没结束时也算活跃:请求已经在读写缓存,只是还没有计时起点
340  if (cfg.showCache && (s.isWorking || s.lastTurnAt !== null)) {
341    const left = s.lastTurnAt === null ? 0 : cfg.cacheTtlMs - (s.now - s.lastTurnAt)
342    if (s.isWorking)
343      items.push({ key: 'cache', icon: 'flame', label: label(lang, 'cache'), percent: null, value: label(lang, 'live'), detail: '', valueColor: levelColor(cfg, 'good'), group: 'usage' })
344    else if (left <= 0)
345      items.push({ key: 'cache', icon: 'flake', label: label(lang, 'cache'), percent: null, value: label(lang, 'cold'), detail: '', color: COLD, group: 'usage' })
346    else {
347      const valueColor = levelColor(cfg, cacheLevel(left / cfg.cacheTtlMs))
348      items.push({ key: 'cache', icon: 'flame', label: label(lang, 'cache'), percent: null, value: duration(left), detail: '', valueColor, group: 'usage' })
349    }
350  }
351
352  if (cfg.showOtherLimits) {
353    for (const l of s.limits) {
354      if (l.kind === 'five_hour' || l.kind === 'seven_day') continue
355      if (l.percentUsed >= cfg.conditionalThreshold) items.push(limitItem(l, 'limit'))
356    }
357  }
358
359  if (cfg.showCost && s.costUsd !== null) {
360    items.push({ key: 'cost', icon: 'coin', label: label(lang, 'cost'), percent: null, value: `$${s.costUsd.toFixed(2)}`, detail: '', group: 'usage' })
361  }
362
363  const p = s.perf
364  if (p) {
365    const perf = (key: string, icon: IconName, value: string, level: Level | null, detail = ''): Item => ({
366      key, icon, label: label(lang, key), percent: null, value, detail, group: 'perf',
367      ...(level ? { valueColor: levelColor(cfg, level) } : {}),
368    })
369    if (cfg.showSpeed) {
370      const tps = p.decodeMs > 0 ? Math.round(p.decodeTokens / (p.decodeMs / 1000)) : null
371      items.push(perf('speed', 'bolt', tps === null ? DASH : `${tps} tok/s`, tps === null ? null : speedLevel(tps)))
372    }
373    if (cfg.showFirstToken) {
374      const secs = p.ttftSteps > 0 ? Math.round(p.ttftMs / p.ttftSteps / 100) / 10 : null
375      items.push(perf('first_token', 'hourglass', secs === null ? DASH : `${secs.toFixed(1)}s`, secs === null ? null : firstTokenLevel(secs)))
376    }
377    // 为 0 时也显示,第二行才能和第一行对齐;大于 0 就标红
378    if (cfg.showFailures) items.push(perf('failures', 'cross', String(p.failures), p.failures > 0 ? 'bad' : 'good'))
379    if (cfg.showCacheHit) {
380      const hit = p.hitPercent
381      // 写缓存为主时另外标出“写入”,颜色照样按命中率分档;和速度、首字一样按显示出来的数分档
382      const text = hit === null ? DASH : hitText(hit)
383      const level = hit === null ? null : hitLevel(Number.parseFloat(text))
384      items.push(perf('cache_hit', 'target', text, level, p.hitWrite ? label(lang, 'write') : ''))
385    }
386  }
387  return items
388}
389
390// ---- 排版:估算宽度,一行放不下时拆成两行 ----
391
392// 文字宽度估算。桌面版用 Chromium 画 SVG,字体是 -apple-system(中文落到苹方);
393// 下面两张表是在 Chrome 里实测的 ASCII 字符宽度(单位 em,从空格到 ~),常规体和半粗体各一张。
394// 中日文和全角符号按 1em,其余字符按 0.6em
395const ASCII_400 = [0.33,0.33,0.43,0.6,0.6,0.97,0.73,0.24,0.33,0.33,0.51,0.61,0.26,0.61,0.26,0.5,0.6,0.4,0.6,0.6,0.6,0.6,0.6,0.55,0.6,0.6,0.26,0.26,0.61,0.61,0.61,0.54,0.86,0.66,0.68,0.73,0.71,0.64,0.58,0.75,0.72,0.24,0.52,0.69,0.59,0.88,0.72,0.77,0.64,0.77,0.68,0.63,0.62,0.71,0.64,0.93,0.64,0.66,0.62,0.33,0.5,0.33,0.52,0.5,0.33,0.56,0.59,0.55,0.59,0.56,0.37,0.59,0.56,0.26,0.27,0.53,0.24,0.86,0.56,0.59,0.59,0.59,0.37,0.51,0.36,0.56,0.48,0.76,0.51,0.5,0.49,0.33,0.2,0.33,0.5]
396const ASCII_600 = [0.33,0.33,0.49,0.6,0.6,1,0.75,0.28,0.33,0.33,0.52,0.61,0.28,0.61,0.28,0.5,0.6,0.41,0.6,0.6,0.6,0.6,0.6,0.55,0.6,0.6,0.28,0.28,0.61,0.61,0.61,0.56,0.87,0.68,0.69,0.73,0.72,0.64,0.58,0.76,0.74,0.26,0.54,0.71,0.59,0.9,0.73,0.78,0.66,0.78,0.7,0.65,0.62,0.73,0.66,0.95,0.66,0.69,0.64,0.33,0.5,0.33,0.55,0.5,0.33,0.57,0.6,0.56,0.6,0.57,0.38,0.6,0.58,0.28,0.28,0.55,0.25,0.88,0.58,0.6,0.6,0.6,0.37,0.53,0.36,0.58,0.51,0.78,0.54,0.53,0.51,0.33,0.21,0.33,0.5]
397
398export function textWidth(text: string, size = 13, weight: 400 | 600 = 400): number {
399  const table = weight === 600 ? ASCII_600 : ASCII_400
400  let w = 0
401  for (const ch of text) {
402    const code = ch.codePointAt(0)!
403    if (code >= 32 && code < 127) w += table[code - 32]!
404    else if (/[\u3000-\u9fff\uff00-\uffef]/.test(ch)) w += 1
405    else w += 0.6
406  }
407  return Math.ceil(w * size)
408}
409
410export const ICON_W = 20
411export const GAP = 22
412// 进度条短一点,空槽不会喧宾夺主
413export const BAR_MAX = 80
414export const BAR_MIN = 40
415
416// 一项除进度条之外的宽度
417export function fixedWidth(item: Item, cfg: Config): number {
418  return (cfg.icons ? ICON_W : 0) + textWidth(item.label) + 6 + textWidth(item.value, 13, 600) + (item.detail ? 8 + textWidth(item.detail, 12) : 0)
419}
420
421const hasBar = (item: Item, cfg: Config) => cfg.bar !== 'none' && item.percent !== null
422
423export function rowWidth(items: Item[], cfg: Config, barW: number): number {
424  return items.reduce((w, it, i) => w + (i > 0 ? GAP : 0) + fixedWidth(it, cfg) + (hasBar(it, cfg) ? barW + 8 : 0), 0)
425}
426
427// 给定可用宽度,求这一行能用的进度条宽度;放不下返回 null
428export function fitBar(items: Item[], cfg: Config, avail: number): number | null {
429  const bars = items.filter(it => hasBar(it, cfg)).length
430  // barW 为 0 时,rowWidth 已经算进每根进度条两侧的 8px 间距
431  const fixed = rowWidth(items, cfg, 0)
432  if (bars === 0) return fixed <= avail ? 0 : null
433  const per = Math.floor((avail - fixed) / bars)
434  if (per < BAR_MIN) return null
435  return Math.min(BAR_MAX, per)
436}
437
438// slots[i] 是第 i 项至少占的宽度,用来让两行的列对齐;没有时按内容紧排
439export type Row = { items: Item[]; barW: number; slots?: number[] }
440
441export function itemWidth(item: Item, cfg: Config, barW: number): number {
442  return fixedWidth(item, cfg) + (hasBar(item, cfg) ? barW + 8 : 0)
443}
444
445// 把 items 按原顺序切成 k 行,让最宽的那行尽量窄(项数很少,直接递归枚举)
446function balance(items: Item[], cfg: Config, k: number): Item[][] {
447  if (k <= 1 || items.length <= 1) return [items]
448  let best: Item[][] = [items]
449  let bestW = Infinity
450  for (let cut = 1; cut <= items.length - k + 1; cut++) {
451    const rest = balance(items.slice(cut), cfg, k - 1)
452    const rows = [items.slice(0, cut), ...rest]
453    const w = Math.max(...rows.map(r => rowWidth(r, cfg, BAR_MIN)))
454    if (w < bestW) {
455      bestW = w
456      best = rows
457    }
458  }
459  return best
460}
461
462// 每行进度条都取最短时,按顺序装箱需要几行
463function rowsNeeded(items: Item[], cfg: Config, avail: number): number {
464  let rows = 1
465  let line: Item[] = []
466  for (const it of items) {
467    if (line.length > 0 && rowWidth([...line, it], cfg, BAR_MIN) > avail) {
468      rows++
469      line = []
470    }
471    line.push(it)
472  }
473  return rows
474}
475
476function wrap(items: Item[], cfg: Config, avail: number, min: number): Row[] {
477  const one = (list: Item[]): Row => ({ items: list, barW: fitBar(list, cfg, avail) ?? BAR_MIN })
478  const k = Math.max(Math.min(min, items.length), rowsNeeded(items, cfg, avail))
479  return balance(items, cfg, k).map(one)
480}
481
482// 两行逐列取较宽的一项作为这一列的宽度。对齐后放不下时先把进度条缩短,
483// 缩到最短仍放不下才不对齐;否则窗口稍窄一点,整行就会在对齐和紧排之间来回跳
484function align(rows: [Row, Row], cfg: Config, avail: number): Row[] {
485  // 没有进度条的行 barW 是 0,不能参与取最小值
486  const withBars = rows.filter(r => r.items.some(it => hasBar(it, cfg)))
487  const start = withBars.length > 0 ? Math.min(...withBars.map(r => r.barW)) : 0
488  const cols = Math.min(rows[0].items.length, rows[1].items.length)
489  for (let barW = start; barW >= Math.min(start, BAR_MIN); barW--) {
490    const slots = Array.from({ length: cols }, (_, i) =>
491      Math.max(itemWidth(rows[0].items[i]!, cfg, barW), itemWidth(rows[1].items[i]!, cfg, barW)),
492    )
493    const width = (r: Row) =>
494      r.items.reduce((w, it, i) => w + (i > 0 ? GAP : 0) + (i < cols ? slots[i]! : itemWidth(it, cfg, barW)), 0)
495    if (rows.every(r => width(r) <= avail)) return rows.map(r => ({ ...r, barW, slots }))
496  }
497  return rows
498}
499
500export function layoutRows(items: Item[], cfg: Config, avail: number): Row[] {
501  if (items.length === 0) return []
502  if (cfg.layout === 'one-line') return [{ items, barW: fitBar(items, cfg, avail) ?? BAR_MIN }]
503  const usage = items.filter(it => it.group === 'usage')
504  const perf = items.filter(it => it.group === 'perf')
505  // 只有一组时按原来的规则换行
506  if (usage.length === 0 || perf.length === 0) return wrap(items, cfg, avail, cfg.layout === 'two-lines' ? 2 : 1)
507  // 两组各占一行:额度在上,性能在下;某一组一行放不下时,这一组自己再拆
508  const rows = [...wrap(usage, cfg, avail, 1), ...wrap(perf, cfg, avail, 1)]
509  return rows.length === 2 ? align(rows as [Row, Row], cfg, avail) : rows
510}
511
512// 进度条点阵用的确定性噪声,同一位置每次画出来一样
513export function noise(c: number, r: number): number {
514  const x = Math.sin(c * 12.9898 + r * 78.233) * 43758.5453
515  return x - Math.floor(x)
516}
517
hooks/svg.ts 107 lines
1// 桌面版的画法:一行画成一张 SVG。文字和底色用 prefers-color-scheme 跟随深浅主题。
2import { colorFor, GAP, ICON_W, noise, textWidth } from './model'
3import type { Config, IconName, Item, Row } from './model'
4
5export const ROW_H = 26
6const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif"
7const BAR_H = 10
8const CELL = 3
9
10const xml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
11
12// 图标都是 14×14 的线条图,用 currentColor 描边,颜色由外层 <g> 给
13const ICON_PATHS: Record<IconName, string> = {
14  clock: '<circle cx="7" cy="7" r="6"/><path d="M7 3.5V7l2.5 1.5"/>',
15  calendar: '<rect x="1" y="2.5" width="12" height="10.5" rx="2"/><path d="M1 6h12M4.5 1v3M9.5 1v3"/>',
16  flame: '<path d="M7 1c1 2.5 4 4 4 7.5A4 4 0 0 1 3 8.5c0-1.5.8-2.6 1.6-3.3.2 1.3.9 2 1.6 2.3C6.4 5 6.4 3 7 1z"/>',
17  flake: '<path d="M7 1v12M1.8 4l10.4 6M1.8 10l10.4-6"/>',
18  layers: '<path d="M7 1.5l5.5 2.8L7 7.1 1.5 4.3z"/><path d="M1.5 7l5.5 2.8L12.5 7"/><path d="M1.5 9.7l5.5 2.8 5.5-2.8"/>',
19  coin: '<circle cx="7" cy="7" r="6"/><path d="M8.8 4.8C8.4 4.2 7.7 4 7 4c-1.1 0-2 .6-2 1.5S5.9 6.8 7 7s2 .6 2 1.5S8.1 10 7 10c-.8 0-1.5-.3-1.9-.9M7 3v1M7 10v1"/>',
20  limit: '<path d="M7 1.5l5.5 10h-11z"/><path d="M7 5.5v3M7 10.2v.1"/>',
21  bolt: '<path d="M8 1L3 8h4l-1 5 5-7H7z"/>',
22  hourglass: '<path d="M3 1.5h8M3 12.5h8M4 1.5c0 3 3 3.5 3 5.5s-3 2.5-3 5.5M10 1.5c0 3-3 3.5-3 5.5s3 2.5 3 5.5"/>',
23  target: '<circle cx="7" cy="7" r="6"/><circle cx="7" cy="7" r="3"/><circle cx="7" cy="7" r=".6"/>',
24  cross: '<circle cx="7" cy="7" r="6"/><path d="M4.8 4.8l4.4 4.4M9.2 4.8L4.8 9.2"/>',
25}
26
27function icon(name: IconName, x: number, color: string | undefined): string {
28  const stroke = color ? ` stroke="${color}"` : ' class="mute-s"'
29  return `<g transform="translate(${x},6)" fill="none"${stroke} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</g>`
30}
31
32function bar(x: number, w: number, percent: number, color: string, style: Config['bar']): string {
33  const y = (ROW_H - BAR_H) / 2
34  const fill = Math.round((w * Math.max(0, Math.min(100, percent))) / 100)
35  const parts = [`<rect class="track" x="${x}" y="${y}" width="${w}" height="${BAR_H}" rx="${BAR_H / 2}"/>`]
36  if (style === 'solid') {
37    if (fill > 0) parts.push(`<rect x="${x}" y="${y}" width="${Math.max(fill, BAR_H)}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>`)
38    return parts.join('')
39  }
40  // 像素点阵:越靠右越密
41  // 有用量就至少画一列点
42  const cols = percent > 0 ? Math.max(1, Math.floor(fill / CELL)) : 0
43  const rows = Math.floor(BAR_H / CELL)
44  const dots: string[] = []
45  for (let c = 0; c < cols; c++) {
46    const density = 0.75 + 0.25 * Math.pow(c / Math.max(1, cols), 0.8)
47    for (let r = 0; r < rows; r++) {
48      if (noise(c, r) < density) dots.push(`<rect x="${x + c * CELL + 0.5}" y="${y + r * CELL + 0.5}" width="2" height="2"/>`)
49    }
50  }
51  parts.push(`<g fill="${color}">${dots.join('')}</g>`)
52  return parts.join('')
53}
54
55// width 是整行可用宽度:画布铺满整行,内容靠左,这样外层怎么排都不会被居中
56// width 是画布宽度;content 是几行里最宽的内容宽度,内容按它在画布里居中,
57// 几行用同一个值,列才不会错开。不传时按这一行自己的内容算
58export function rowSvg(row: Row, cfg: Config, width = 0, content?: number): { source: string; width: number } {
59  const out: string[] = []
60  let x = 2
61  row.items.forEach((it, i) => {
62    if (i > 0) x += GAP
63    const start = x
64    const tone = it.color ?? (it.percent !== null ? colorFor(cfg, it.percent) : undefined)
65    if (cfg.icons) {
66      out.push(icon(it.icon, x, it.color))
67      x += ICON_W
68    }
69    out.push(`<text class="mute" x="${x}" y="17.5" font-size="13">${xml(it.label)}</text>`)
70    x += textWidth(it.label) + 6
71    if (cfg.bar !== 'none' && it.percent !== null && tone) {
72      out.push(bar(x, row.barW, it.percent, tone, cfg.bar))
73      x += row.barW + 8
74    }
75    // 数值和进度条同色;第二行按阈值另给 valueColor
76    const valueTone = it.color ?? it.valueColor ?? tone
77    const valueFill = valueTone ? ` fill="${valueTone}"` : ' class="main"'
78    out.push(`<text x="${x}" y="17.5" font-size="13" font-weight="600"${valueFill}>${xml(it.value)}</text>`)
79    x += textWidth(it.value, 13, 600)
80    if (it.detail) {
81      x += 8
82      out.push(`<text class="mute" x="${x}" y="17.5" font-size="12">${xml(it.detail)}</text>`)
83      x += textWidth(it.detail, 12)
84    }
85    // 两行对齐时,这一项至少占到这一列的宽度
86    const slot = row.slots?.[i]
87    if (slot !== undefined) x = Math.max(x, start + slot)
88  })
89  const inner = Math.max(content ?? 0, Math.ceil(x) + 2)
90  const w = Math.max(inner, Math.floor(width))
91  // 估算的画布宽度通常比内容宽;多出来的平分到两边,左右留白才一样
92  const dx = Math.floor((w - inner) / 2)
93  const body = dx > 0 ? `<g transform="translate(${dx},0)">${out.join('')}</g>` : out.join('')
94  const source =
95    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${ROW_H}" viewBox="0 0 ${w} ${ROW_H}" font-family="${FONT}">` +
96    '<style>.main{fill:#1f1f1f}.mute{fill:#6f6f6f}.mute-s{stroke:#6f6f6f}.track{fill:rgba(0,0,0,.06)}' +
97    '@media (prefers-color-scheme: dark){.main{fill:#ececec}.mute{fill:#8a8a8a}.mute-s{stroke:#8a8a8a}.track{fill:rgba(255,255,255,.07)}}</style>' +
98    body +
99    '</svg>'
100  return { source, width: w }
101}
102
103// 读屏用的一句话
104export function rowAlt(row: Row): string {
105  return row.items.map(it => [it.label, it.value, it.detail].filter(Boolean).join(' ')).join(', ')
106}
107
types/index.d.ts 22 lines
1// usage-bar 在 $.state 里保存的值
2declare module 'claude-code' {
3  interface PluginState {
4    'usage-bar': {
5      // 主对话最近一轮结束的时间($.clock.now() 的毫秒),用来算缓存还剩多久
6      lastTurnAt: number | null
7      // 每分钟加一,让倒计时在空闲时也会重画
8      tick: number
9      // 本会话的请求性能,字段和 hooks/model.ts 的 Perf 一致
10      perf: {
11        ttftMs: number
12        ttftSteps: number
13        decodeMs: number
14        decodeTokens: number
15        hitPercent: number | null
16        hitWrite: boolean
17        failures: number
18      } | null
19    }
20  }
21}
22