SLOPSHOPPER

Token Pulse

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

bandcommand
v0.2.3Apache-2.0updated 2026-10-20gKev1n/token-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-pulse
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /token-pulse ⎿ token-pulse: token-pulse 状态带已隐藏。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

token-pulse

中文 | 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.

Desktop app (Code tab)

token-pulse in the Claude desktop app

Terminal (CLI)

token-pulse in the Claude Code terminal

The energy mascot

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 usedStateDesktop appTerminalColor
< 25%FreshFresh laughing, rosy cheeks(^‿^)green
25–50%FocusedFocused round eyes, slight smile(•_•)teal
50–75%TiredTired slit eyes, yawning(-_-)orange
75–90%DrainedDrained drooping eyes, wavy mouth, sweat drop(=_=;)coral
≥ 90%ExhaustedExhausted asleep, zZ overhead(-_-)zZred, compaction is near

The state word (Fresh, Tired, …) and the trend bar use the same color.

What each item means

ItemMeaning
67%Share of the context window in use
134.4k/200kTokens used / window size
▃▅█Context usage at the end of each of the last 12 turns
▲ +98.4kChange in context tokens since the previous turn
⇢ 68 tok/sOutput speed of the latest model request
均 72Average 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.

How the numbers are computed

  • Output speed: output tokens ÷ generation time. Generation time runs from the first streamed chunk to the end of the stream, so queueing and time to first token are excluded. It is measured on your machine and affected by the network; thinking tokens count as output. Requests with fewer than 20 output tokens or under 0.3 s are skipped as noise.
  • Cache hit rate: cache reads ÷ (uncached input + cache writes + cache reads). It is normally low on a session's first request, right after compaction, or after the cache expires (5 minutes by default).
  • Only the main conversation is counted; subagent requests are not. /clear resets the cumulative figures.

Usage

  • /token-pulse shows or hides the band.
  • Nothing is shown before the first model response.
  • After the first turn you see usage, token counts, speed and hit rate; the trend bar, the ▲ change and the average need at least two data points and appear from the second turn.

Install

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.

Privacy and data

  • Makes no network requests, sends no data to any service, and reads or writes no files on disk.
  • Reads only what Claude Code already provides: per-request token usage (input, cache reads, cache writes, output), context window usage, and the local clock (to time generation). The model's streamed output passes through the turn.step hook, which forwards it unchanged and only notes when the first chunk arrives; it neither parses nor keeps the content.
  • Figures are kept only in the current session's state ($.state), dropped when the session ends, and reset on /clear. Nothing is kept across sessions.

What each hook in hooks/register.tsx does:

HookWhat it does
session.startRegisters 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.endOn /clear, resets the figures; then passes the event on
turn.stepForwards 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.completeReads 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

Known issues

  • In Windows terminals with a CJK font, characters such as • may render two cells wide and shift the kaomoji slightly.

Development

claude plugin validate .
claude plugin test .
  • Decide the drawing surface with 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.
  • In tests, 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: ... }.
  • The five mascot images in this README are exported from mascotSvg() in hooks/icon.ts. Re-export them after changing the mascot.

License

Apache-2.0 © 2026 gKev1n

Source 4 files
hooks/register.tsx 123 lines
1import { 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}
123
hooks/icon.ts 68 lines
1/** 精力值分级:上下文越满,小人越累;压缩上下文就是让它睡一觉 */
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}
68
hooks/metrics.ts 103 lines
1import 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}
103
types/index.d.ts 33 lines
1/** 一次输出速度采样:本次请求生成的 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