A status band above the prompt: an energy mascot for context usage, usage trend, model output speed (tok/s) and prompt cache hit rate

中文 | English
A Claude Code mod that adds a status band above the prompt: context usage with an energy mascot, model output speed, and prompt cache hit rate. It replaces token-weather.
(-_-) Tired 67% 134.4k/200k ▃▅█ ▲ +98.4k │ ⇢ 68 tok/s · 均 72 │ ⟳ 缓存命中 94% · 累计 88%
The band's labels are in Chinese: 均 = average, 缓存命中 = cache hit, 累计 = cumulative.


The fuller the context, the more tired the mascot gets; compacting the context lets it sleep. The desktop app (Code tab) draws it as an SVG; the terminal cannot draw images, so it shows a kaomoji instead.
| Context used | State | Desktop app | Terminal | Color |
|---|---|---|---|---|
| < 25% | Fresh | (^‿^) | green | |
| 25–50% | Focused | (•_•) | teal | |
| 50–75% | Tired | (-_-) | orange | |
| 75–90% | Drained | (=_=;) | coral | |
| ≥ 90% | Exhausted | (-_-)zZ | red, compaction is near |
The state word (Fresh, Tired, …) and the trend bar use the same color.
| Item | Meaning |
|---|---|
| 67% | Share of the context window in use |
| 134.4k/200k | Tokens used / window size |
| ▃▅█ | Context usage at the end of each of the last 12 turns |
| ▲ +98.4k | Change in context tokens since the previous turn |
| ⇢ 68 tok/s | Output speed of the latest model request |
| 均 72 | Average output speed over the last 10 valid requests, weighted by tokens |
| ⟳ 缓存命中 94% | Prompt cache hit rate of the latest request: green ≥ 80%, yellow ≥ 50%, red < 50% |
| 累计 88% | Cumulative cache hit rate over all requests in this session |
In a narrow terminal (band under 96 columns) only the mascot, state, usage, speed and hit rate are shown. Narrow mode applies to the terminal only: the desktop app reports its width differently, and the same threshold would hide content there, so the desktop app always shows everything.
/clear resets the cumulative figures./token-pulse shows or hides the band.Requires Claude Code 2.1.287 or later. At the Claude Code prompt:
/plugin install token-pulse --marketplace gKev1n/token-pulse
Answer y to add the marketplace, then pick a scope. Or from a shell:
claude plugin install token-pulse --marketplace gKev1n/token-pulse
In a session that is already running, run /reload-plugins.
If token-weather is installed, disable or uninstall it first, or you will see two bands.
To work on the code, clone the repository and add the local folder instead:
git clone https://github.com/gKev1n/token-pulse.git ~/mods/token-pulse
claude plugin marketplace add ~/mods/token-pulse
claude plugin install token-pulse@token-pulse
After that, edits to the folder take effect with /reload-plugins; no reinstall needed.
turn.step hook, which forwards it unchanged and only notes when the first chunk arrives; it neither parses nor keeps the content.$.state), dropped when the session ends, and reset on /clear. Nothing is kept across sessions.What each hook in hooks/register.tsx does:
| Hook | What it does |
|---|---|
session.start | Registers the /token-pulse command, then passes the event on |
command.run (only token-pulse) | Answers the mod's own /token-pulse command by toggling the band; other commands never reach it |
session.end | On /clear, resets the figures; then passes the event on |
turn.step | Forwards the model's stream unchanged, timing it from the first chunk to the end, and reads the request's token usage. Subagent requests are ignored |
turn.complete | Reads the context window usage at the end of a main-conversation turn |
ui.render (only AbovePrompt) | Draws the band; when hidden, before the first response, or while a survey is shown, it leaves the area to Claude Code |
• may render two cells wide and shift the kaomoji slightly.claude plugin validate .
claude plugin test .
e.surface !== 'terminal'. The terminal's element table also has an Svg key, so checking 'Svg' in els alone would draw SVG in the terminal.ui.render needs an answer at the bottom of the chain from the test itself (see engineDraws in the test file), and the bottom answer for session.usage must be wrapped as { value: ... }.mascotSvg() in hooks/icon.ts. Re-export them after changing the mascot.Apache-2.0 © 2026 gKev1n
hooks/register.tsx 123 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { levelFor, mascotSvg } from './icon'
5import { EMPTY, addStep, addTurn, avgTps, deltaText, hitRate, pct, short, sparkline } from './metrics'
6
7const pulse = atom({ plugin: 'token-pulse', key: 'pulse' } as const, EMPTY)
8const isHidden = atom({ plugin: 'token-pulse', key: 'isHidden' } as const, false)
9
10/** 状态带宽度低于这个列数时,只显示主要数字 */
11const COMPACT_COLUMNS = 96
12
13export const register: Register = on => {
14 on('session.start', async ($, e, next) => {
15 await $.command.register({
16 name: 'token-pulse',
17 description: '显示或隐藏输入框上方的 token-pulse 状态带',
18 })
19 return next(e)
20 })
21
22 on('command.run', { command: 'token-pulse' }, async $ => {
23 const hidden = await update($, isHidden, v => !v)
24 return { text: hidden ? 'token-pulse 状态带已隐藏。' : 'token-pulse 状态带已显示。' }
25 })
26
27 // /clear 之后是新的对话,累计数据清零
28 on('session.end', async ($, e, next) => {
29 if (e.reason === 'clear') await update($, pulse, () => EMPTY)
30 return next(e)
31 })
32
33 // 每次模型请求:从收到第一个流片段开始计时,到流结束为止,作为生成耗时。
34 // 不从发出请求开始计,是为了把排队和读取上下文(首字延迟)排除在外。
35 on('turn.step', async function* ($, e, next) {
36 const stream = next(e)
37 let firstAt: number | null = null
38 for await (const chunk of stream) {
39 if (firstAt === null) firstAt = await $.clock.now()
40 yield chunk
41 }
42 const result = await stream.result
43 // 子代理的请求会和主线程并行,混进来会让"最近一次"失真,只统计主线程
44 if (e.agentId === undefined && result.usage) {
45 const genMs = firstAt === null ? null : (await $.clock.now()) - firstAt
46 const usage = result.usage
47 await update($, pulse, p => addStep(p, usage, genMs))
48 }
49 return result
50 })
51
52 on('turn.complete', async ($, e, next) => {
53 if (e.agentId === undefined) {
54 const { context } = await $.session.usage({})
55 await update($, pulse, p => addTurn(p, context))
56 }
57 return next(e)
58 })
59
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 const p = await read($, pulse)
62 if (e.props.hasSurvey || (await read($, isHidden)) || p.ctxPercent === null) {
63 return next(e)
64 }
65
66 const els = $.ui.resolve(e)
67 const { Box, Text } = els
68 // 只有终端按列数切换窄屏:桌面应用报告的 bodyColumns 和终端算法不同,
69 // 宽屏下也会偏小,按同一阈值判断会把内容误藏掉
70 const isCompact = e.surface === 'terminal' && e.props.bodyColumns < COMPACT_COLUMNS
71 const level = levelFor(p.ctxPercent)
72 const tpsAvg = avgTps(p.samples)
73 const cacheAll = hitRate(p.cacheRead, p.inputTotal)
74 const delta = deltaText(p.ctxDelta)
75
76 // 终端画不了 SVG,用颜文字;其他界面画 SVG 小人。
77 // 只看 'Svg' in els 不够:终端的元素表里也有这个键,所以先按界面判断。
78 const icon =
79 e.surface !== 'terminal' && 'Svg' in els ? (
80 <els.Svg source={mascotSvg(level)} alt={`${level.word} ${level.kaomoji}`} width={25} height={18} />
81 ) : (
82 <Text color={level.color}>{level.kaomoji}</Text>
83 )
84
85 return (
86 <Box>
87 {icon}
88 <Text color={level.color} bold>
89 {' '}
90 {level.word}
91 </Text>
92 <Text> </Text>
93 <Text>{p.ctxPercent}%</Text>
94 {!isCompact && p.ctxTokens !== null && p.ctxWindow !== null && (
95 <Text dimColor>
96 {' '}
97 {short(p.ctxTokens)}/{short(p.ctxWindow)}
98 </Text>
99 )}
100 {!isCompact && p.history.length > 1 && <Text color={level.color}> {sparkline(p.history)}</Text>}
101 {!isCompact && delta !== null && <Text dimColor> {delta}</Text>}
102
103 <Text dimColor> │ </Text>
104 <Text>⇢ </Text>
105 <Text>{p.tpsLast === null ? '–' : `${Math.round(p.tpsLast)} tok/s`}</Text>
106 {!isCompact && tpsAvg !== null && p.samples.length > 1 && (
107 <Text dimColor> · 均 {Math.round(tpsAvg)}</Text>
108 )}
109
110 <Text dimColor> │ </Text>
111 <Text>⟳ 缓存命中 </Text>
112 <Text color={cacheColor(p.cacheLast)}>{p.cacheLast === null ? '–' : pct(p.cacheLast)}</Text>
113 {!isCompact && cacheAll !== null && <Text dimColor> · 累计 {pct(cacheAll)}</Text>}
114 </Box>
115 )
116 })
117}
118
119function cacheColor(ratio: number | null): 'success' | 'warning' | 'error' | undefined {
120 if (ratio === null) return undefined
121 return ratio >= 0.8 ? 'success' : ratio >= 0.5 ? 'warning' : 'error'
122}
123hooks/icon.ts 68 lines1/** 精力值分级:上下文越满,小人越累;压缩上下文就是让它睡一觉 */
2export type Level = {
3 upTo: number
4 word: string
5 /** 小人和趋势条的颜色;中间色调在深浅主题下都看得清 */
6 color: string
7 /** 终端里的颜文字;只用单宽字符,避免在终端里错位 */
8 kaomoji: string
9 face: 'fresh' | 'focused' | 'tired' | 'drained' | 'asleep'
10}
11
12export const LEVELS: readonly Level[] = [
13 { upTo: 25, word: 'Fresh', color: '#97C459', kaomoji: '(^‿^)', face: 'fresh' },
14 { upTo: 50, word: 'Focused', color: '#5DCAA5', kaomoji: '(•_•)', face: 'focused' },
15 { upTo: 75, word: 'Tired', color: '#EF9F27', kaomoji: '(-_-)', face: 'tired' },
16 { upTo: 90, word: 'Drained', color: '#F0997B', kaomoji: '(=_=;)', face: 'drained' },
17 { upTo: Infinity, word: 'Exhausted', color: '#E24B4A', kaomoji: '(-_-)zZ', face: 'asleep' },
18]
19
20export function levelFor(percent: number): Level {
21 return LEVELS.find(l => percent < l.upTo) ?? LEVELS[LEVELS.length - 1]!
22}
23
24const INK = '#2C2C2A'
25
26const line = (d: string, w = 1.4) =>
27 `<path d="${d}" fill="none" stroke="${INK}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`
28const dot = (x: number, y: number, r = 1.3) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${INK}"/>`
29const cheeks = '<circle cx="4.6" cy="13.4" r="1.5" fill="#F09595"/><circle cx="15.4" cy="13.4" r="1.5" fill="#F09595"/>'
30
31function face(kind: Level['face']): string {
32 switch (kind) {
33 case 'fresh': // 眯眼大笑,脸红
34 return (
35 line('M5 10q1.6-2 3.2 0M11.8 10q1.6-2 3.2 0') +
36 `<path d="M7 13.2h6q-0.4 3.4-3 3.4t-3-3.4z" fill="${INK}"/>` +
37 cheeks
38 )
39 case 'focused': // 认真:圆眼 + 浅笑,不加眉毛(加了像生气)
40 return dot(6.6, 10.2, 1.5) + dot(13.4, 10.2, 1.5) + line('M8.2 13.6q1.8 1.4 3.6 0')
41 case 'tired': // 眼睛眯成线 + 打哈欠
42 return line('M5 10.4h3.2M11.8 10.4h3.2') + `<ellipse cx="10" cy="14.4" rx="1.3" ry="1.6" fill="${INK}"/>`
43 case 'drained': // 下垂眼(外低内高)+ 波浪嘴 + 汗珠
44 return (
45 line('M5 11.2l3-1.2M15 11.2l-3-1.2') +
46 line('M7.6 14.6q1.2-1 2.4 0t2.4 0', 1.2) +
47 '<path d="M18.2 3.4q1.6 2.4 0 3.5q-1.6-1.1 0-3.5z" fill="#85B7EB"/>'
48 )
49 case 'asleep': // 睡着:弯眼 + 平嘴 + zZ(灰色,深浅背景都看得见)
50 return (
51 line('M5 10.4q1.6 1.6 3.2 0M11.8 10.4q1.6 1.6 3.2 0') +
52 line('M8.8 14.4h2.4') +
53 `<text x="19.6" y="8.4" font-family="sans-serif" font-size="5.2" font-weight="700" fill="#888780">z</text>` +
54 `<text x="22.6" y="5" font-family="sans-serif" font-size="7" font-weight="700" fill="#888780">Z</text>`
55 )
56 }
57}
58
59/** 桌面版小人:圆脸 + 表情,右侧留出放 zZ 和汗珠的位置,28×20 */
60export function mascotSvg(level: Level): string {
61 return (
62 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 20" width="28" height="20">` +
63 `<circle cx="10" cy="11" r="8.6" fill="${level.color}"/>` +
64 face(level.face) +
65 `</svg>`
66 )
67}
68hooks/metrics.ts 103 lines1import type { ModelUsage } from 'claude-code'
2
3import type { Pulse, Sample } from '../types'
4
5/** 趋势图保留的轮数 */
6export const HISTORY_LEN = 12
7/** 平均速度取最近多少次有效采样 */
8export const SAMPLE_LEN = 10
9/** 生成太短的请求(只调了个工具、只回一两个字)测出来的速度噪声很大,不计入 */
10export const MIN_TOKENS = 20
11export const MIN_MS = 300
12
13export const EMPTY: Pulse = {
14 ctxPercent: null,
15 ctxTokens: null,
16 ctxWindow: null,
17 ctxDelta: null,
18 history: [],
19 tpsLast: null,
20 samples: [],
21 cacheLast: null,
22 cacheRead: 0,
23 inputTotal: 0,
24}
25
26/** 一次请求的全部输入 token:未缓存 + 缓存写入 + 缓存读取 */
27export function inputOf(u: ModelUsage): number {
28 return u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
29}
30
31/** 缓存命中率 = 缓存读取 ÷ 全部输入;没有输入时返回 null */
32export function hitRate(read: number, total: number): number | null {
33 return total > 0 ? read / total : null
34}
35
36/** 平均速度按 token 加权(总 token ÷ 总耗时),避免短请求把平均值拉偏 */
37export function avgTps(samples: readonly Sample[]): number | null {
38 let tokens = 0
39 let ms = 0
40 for (const s of samples) {
41 tokens += s.tokens
42 ms += s.ms
43 }
44 return ms > 0 ? (tokens * 1000) / ms : null
45}
46
47/** 把一次请求的 usage 和生成耗时并入状态 */
48export function addStep(p: Pulse, usage: ModelUsage, genMs: number | null): Pulse {
49 const total = inputOf(usage)
50 const next: Pulse = {
51 ...p,
52 cacheLast: hitRate(usage.cache_read_input_tokens, total) ?? p.cacheLast,
53 cacheRead: p.cacheRead + usage.cache_read_input_tokens,
54 inputTotal: p.inputTotal + total,
55 }
56 if (genMs !== null && usage.output_tokens >= MIN_TOKENS && genMs >= MIN_MS) {
57 const sample = { tokens: usage.output_tokens, ms: genMs }
58 next.tpsLast = (sample.tokens * 1000) / sample.ms
59 next.samples = [...p.samples, sample].slice(-SAMPLE_LEN)
60 }
61 return next
62}
63
64/** 一轮结束时记下上下文占用,追加到趋势 */
65export function addTurn(p: Pulse, ctx: { tokens?: number; window: number; percent?: number }): Pulse {
66 if (ctx.percent === undefined || ctx.tokens === undefined) return p
67 return {
68 ...p,
69 ctxPercent: ctx.percent,
70 ctxTokens: ctx.tokens,
71 ctxWindow: ctx.window,
72 ctxDelta: p.ctxTokens === null ? null : ctx.tokens - p.ctxTokens,
73 history: [...p.history, ctx.percent].slice(-HISTORY_LEN),
74 }
75}
76
77/** 134400 → 134.4k,200000 → 200k */
78export function short(n: number): string {
79 const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(1))
80 if (Math.abs(n) >= 1_000_000) return `${fmt(Math.round(n / 100_000) / 10)}M`
81 if (Math.abs(n) >= 1_000) return `${fmt(Math.round(n / 100) / 10)}k`
82 return String(n)
83}
84
85/** ▲ +98.3k / ▼ 40k / 持平时返回 null */
86export function deltaText(delta: number | null): string | null {
87 if (delta === null || delta === 0) return null
88 return delta > 0 ? `▲ +${short(delta)}` : `▼ ${short(-delta)}`
89}
90
91const BARS = '▁▂▃▄▅▆▇█'
92
93/** 0–100 的百分比画成一行方块字符,不依赖终端的图形能力,各个界面都能显示 */
94export function sparkline(values: readonly number[]): string {
95 return values
96 .map(v => BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor((v / 100) * BARS.length)))])
97 .join('')
98}
99
100export function pct(ratio: number): string {
101 return `${Math.round(ratio * 100)}%`
102}
103types/index.d.ts 33 lines1/** 一次输出速度采样:本次请求生成的 token 数与生成耗时(毫秒) */
2export type Sample = { tokens: number; ms: number }
3
4/** 状态带读取的全部数据,会话内由 host 保存,热重载后不丢失 */
5export type Pulse = {
6 /** 上下文窗口占用百分比(0–100),首次响应前为 null */
7 ctxPercent: number | null
8 /** 上下文已用 token 数 */
9 ctxTokens: number | null
10 /** 上下文窗口大小 */
11 ctxWindow: number | null
12 /** 与上一轮相比上下文增减的 token 数;首轮为 null */
13 ctxDelta: number | null
14 /** 最近若干轮结束时的上下文占用百分比,最多 12 个 */
15 history: number[]
16 /** 最近一次请求的输出速度(tok/s) */
17 tpsLast: number | null
18 /** 最近若干次有效采样,用于算平均速度 */
19 samples: Sample[]
20 /** 最近一次请求的缓存命中率(0–1) */
21 cacheLast: number | null
22 /** 本会话累计:缓存读取的输入 token */
23 cacheRead: number
24 /** 本会话累计:全部输入 token(未缓存 + 缓存写入 + 缓存读取) */
25 inputTotal: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'token-pulse': { pulse: Pulse; isHidden: boolean }
31 }
32}
33