Shows the model's live output tokens per second in the prompt footer.

English · 简体中文
A collection of Claude Code mods. Each mod is a function-hook plugin that runs in the terminal and in the Code tab of Claude Desktop.
| Mod | What it does |
|---|---|
| tps-meter | Shows the model's live output speed (tokens/s) |
In a terminal:
claude plugin marketplace add xingkaixin/claude-mods
claude plugin install tps-meter@claude-mods
Or with slash commands at the Claude Code prompt (Desktop does not support these; use the terminal commands above):
/plugin marketplace add xingkaixin/claude-mods
/plugin install tps-meter@claude-mods
Choose the user scope so both the terminal and Desktop load it. After installing, run /reload-plugins in any terminal session that is already open. Desktop loads it in a new session.
See the installed version:
claude plugin list
The Version line under tps-meter@claude-mods is the installed version.
Check for and install a new version:
claude plugin marketplace update claude-mods # fetch the latest marketplace from GitHub
claude plugin update tps-meter@claude-mods # update if there is a newer version, otherwise report it is up to date
After updating, run /reload-plugins in the terminal, or start a new session in Desktop. Sessions that are already running keep the old version.
The latest version number is in mods/<mod>/.claude-plugin/plugin.json in this repository.
Shows the model's live output speed (tokens/s) while it responds:
| Surface | Where |
|---|---|
| Terminal | At the end of the hint line under the prompt, e.g. after bypass permissions on (shift+tab to cycle) |
| Desktop | In the footer under the input box |
The readout has two states:
⚡ 42.3 tok/s: the model is still responding. Refreshed every 250 ms. This value is estimated from character counts.· 38.1 tok/s: the response has finished. The exact value, computed from the output_tokens the API returns.Before any output, Desktop shows – tok/s, which also tells you the mod is loaded.
Each model request (turn.step) is measured on its own. Timing starts at the first output chunk and ends at the stop chunk, so time to first token is excluded. Thinking, text and tool-call arguments all count as output.
output_tokens after each request. For CJK text the live value of the first request therefore reads low.output_tokens ÷ seconds from the first chunk to the end..claude-plugin/marketplace.json # marketplace manifest, lists every mod
mods/<mod>/
.claude-plugin/plugin.json # mod manifest: name, version, description
hooks/hooks.json # points to the hooks module
hooks/register.tsx # hooks module, exports register(on)
types/index.d.ts # $.state type declarations (needed when the mod uses $.state)
tests/*.test.tsx # tests run by claude plugin test
mods/<mod>/.claude-plugin/types/ holds type files Claude Code generates each time it loads a mod; it is ignored in .gitignore.
mods/ with the files listed above.plugins in .claude-plugin/marketplace.json, with source set to ./mods/<mod>.The authoritative API reference is the type file Claude Code generates, .claude-plugin/types/claude-code/index.d.ts. Loading the plugin-authoring skill in Claude Code gives the full authoring guide.
claude --plugin-dir mods/<mod> # load from the local folder for this session only
claude plugin validate . # check the marketplace and every mod manifest
claude plugin validate mods/<mod> # check one mod's manifest and hooks module
claude plugin test mods/<mod> # run its tests
A terminal session started with --plugin-dir watches the folder and reloads the mod when a file is saved.
version in mods/<mod>/.claude-plugin/plugin.json. The version must change: an installed plugin is copied into a local cache by version, so with the same version claude plugin update does not fetch the new code.main.claude plugin tag mods/<mod> --push to create and push a <mod>--v<version> git tag.hooks/register.tsx 90 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Tps } from '../types'
5
6const tps = atom({ plugin: 'tps-meter', key: 'tps' } as const, null)
7
8const PUBLISH_INTERVAL_MS = 250
9// Below this the elapsed time is mostly chunk-arrival jitter, not generation speed.
10const MIN_ELAPSED_MS = 200
11
12export const register: Register = on => {
13 // Seeded with the usual English ratio; recalibrated from each step's real usage.
14 let charsPerToken = 4
15
16 on('turn.step', async function* ($, e, next) {
17 if (e.agentId !== undefined) {
18 return yield* next(e)
19 }
20
21 let firstAt: number | undefined
22 let lastPublishAt = 0
23 let chars = 0
24
25 const publish = (value: Tps) => update($, tps, () => value)
26
27 for await (const chunk of next(e)) {
28 if (chunk.kind === 'text' || chunk.kind === 'thinking' || chunk.kind === 'input') {
29 const now = await $.clock.now()
30 firstAt ??= now
31 chars += chunk.kind === 'input' ? chunk.json.length : chunk.text.length
32
33 const elapsed = now - firstAt
34 if (elapsed >= MIN_ELAPSED_MS && now - lastPublishAt >= PUBLISH_INTERVAL_MS) {
35 lastPublishAt = now
36 await publish({ value: chars / charsPerToken / (elapsed / 1000), isLive: true })
37 }
38 }
39
40 if (chunk.kind === 'stop' && firstAt !== undefined) {
41 const elapsed = (await $.clock.now()) - firstAt
42 const tokens = chunk.usage?.output_tokens ?? 0
43
44 if (tokens > 0 && chars > 0) {
45 charsPerToken = chars / tokens
46 }
47
48 if (tokens > 0 && elapsed >= MIN_ELAPSED_MS) {
49 await publish({ value: tokens / (elapsed / 1000), isLive: false })
50 } else {
51 const last = await read($, tps)
52 if (last?.isLive) await publish({ ...last, isLive: false })
53 }
54 }
55
56 yield chunk
57 }
58 })
59
60 const label = (current: Tps) => `${current.isLive ? '⚡' : '·'} ${current.value.toFixed(1)} tok/s`
61
62 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
63 const current = await read($, tps)
64 if (current === null || e.surface !== 'terminal') {
65 return next(e)
66 }
67
68 return next({ ...e, props: { ...e.props, tail: ` ${label(current)}` } })
69 })
70
71 // Only the terminal draws PromptHint's tail. The desktop draws a SessionMode tree in its footer,
72 // but not a rewritten `modes`, so the readout is drawn as our own tree around the engine's.
73 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
74 if (e.surface === 'terminal') {
75 return next(e)
76 }
77
78 const current = await read($, tps)
79 const { Box, Text } = $.ui.resolve(e)
80 const below = await next(e)
81
82 return (
83 <Box flexDirection="row" alignItems="center" gap={1}>
84 <Text dimColor>{current === null ? '– tok/s' : label(current)}</Text>
85 {below}
86 </Box>
87 )
88 })
89}
90types/index.d.ts 8 lines1export type Tps = { value: number; isLive: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'tps-meter': { tps: Tps | null }
6 }
7}
8