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…

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.
In the Claude desktop app, open a conversation in the Code tab, then:
zgmengde/usage-bar and click Sync.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.
First row: usage
| Item | What it means | Color |
|---|---|---|
| Context | How 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 |
| 5h | Share of the 5-hour plan limit used, and the time until it resets. | Follows the percentage |
| Week | Share of the weekly plan limit used, and the time until it resets. | Follows the percentage |
| Cache | How 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 |
| Extra | Any other limit window the API reports, such as a spend limit. Shown only once it passes conditional_threshold. | Follows the percentage |
| Cost | The session's estimated API cost. Off by default. On a subscription this is an estimate, not what you pay. | — |
Second row: performance
| Item | What it means | Green | Orange | Red |
|---|---|---|---|---|
| Speed | Output tokens per second for this session's main thread. | ≥ 90 | 70–89 | < 70 |
| TTFT | Average seconds from sending a main-thread request until its response starts. | < 3 s | 3–5 s | > 5 s |
| Failed | Requests this session, subagents included, that errored or ended without a response. Requests you interrupt do not count. | 0 | — | > 0 |
| Cache hit | Share 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.
Speed and time to first token are session totals, summed the way DeepSeek Harness does it in its session stats:
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.
Change them in the plugin's settings screen, or in ~/.claude/settings.json under pluginConfigs["usage-bar@usage-bar"].options.
| Key | Default | What it does |
|---|---|---|
show_five_hour | true | Show the 5-hour limit. |
show_weekly | true | Show the weekly limit. |
show_cache | true | Show the prompt-cache countdown. |
show_other_limits | true | Show other limit windows once they pass conditional_threshold. |
show_context | false | Show context use. |
show_cost | false | Show the session's estimated cost. |
show_reset | true | Show the time left until each limit resets. |
show_speed | true | Second row: output speed. |
show_first_token | true | Second row: time to first token. |
show_cache_hit | true | Second row: cache hit rate. |
show_failures | true | Second row: failed requests. |
conditional_threshold | 50 | Percent at which other limits start to show. |
layout | auto | auto 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. |
bar | dither | dither pixel bars, solid filled bars, none numbers only. |
color | by-percent | by-percent blends through the color stops; threshold changes color at 80% and 95%; mono uses one color. |
color_stops | 0:#46a758,60:#d97757,100:#e5484d | percent:#hex pairs; colors blend by hue between stops. |
icons | on | Show an icon before each item. |
cache_ttl | 1h | Length of the cache countdown. Match it to your prompt-cache TTL (5m or 1h). |
notify_at | 80,95 | Percents of the 5-hour or weekly limit that raise a notification, once per percent per window. Empty turns it off. |
language | auto | Label 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.
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.
LANG and LC_ALL variables.~/.claude/plugins/store/usage-bar_*.json, so the row survives a restart:/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.
bodyColumns × 8 px). The row is centered so the margins stay even, but a text can be off by a few pixels.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 ..
hooks/register.tsx 362 lines1// 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}
362hooks/model.ts 517 lines1// 纯函数:解析配置、算颜色、格式化、排版。不碰 $,方便单测。
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}
517hooks/svg.ts 107 lines1// 桌面版的画法:一行画成一张 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
107types/index.d.ts 22 lines1// 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