Status line showing the model's output speed (tokens/sec), TTFT, total time and session average

English · 简体中文
A tiny Claude Code mod that shows how fast the model is talking, right in the status line. Works in the terminal and in the desktop Code tab.
⚡ 92.3 tps · TTFT 2.1s · total 22.0s · avg 88.0 tps
| Field | Meaning |
|---|---|
tps | Output speed of the last request: usage.output_tokens ÷ generation time (first streamed chunk → end, TTFT excluded) |
TTFT | Time to first token: request sent → first streamed chunk |
total | Wall time of the request: sent → last chunk |
avg | Average speed over every request in this session |
~120 tps (≈4 chars/token for Latin text, ≈1 char/token for CJK). When the response ends it switches to the exact value from the API usage.(sub). git clone https://github.com/Kiyliy/claude-code-tps.git ~/.claude/mods/tps
~/.claude/settings.json (merge into an existing env block; separate several directories with ; on Windows): "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/tps" }
The mod hooks the turn.step stream, timestamps every chunk, reads usage.output_tokens when the step stops, and writes the result with $.ui.status(). The whole thing is hooks/register.ts.
Notes:
⚡, because the engine always prefixes the status line with the plugin name. claude plugin validate warns that the name isn't kebab-case; that only matters for marketplace installs, local loading is fine.claude plugin validate ~/.claude/mods/tps
claude plugin test ~/.claude/mods/tps
The mod API is early access and needs a recent Claude Code; tested on 2.1.293.
hooks/register.ts 53 lines1import type { Register } from 'claude-code'
2
3// Rough live estimate: ~4 chars/token for Latin text, ~1 char/token for CJK.
4const estimateTokens = (s: string) => {
5 let cjk = 0
6 for (const ch of s) if (ch.charCodeAt(0) > 0x2e80) cjk += 1
7 return cjk + (s.length - cjk) / 4
8}
9
10const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
11
12// The plugin is named ⚡: the engine leads the status line with that name.
13export const register: Register = on => {
14 // Session totals (reset on reload, which is fine for a speed meter).
15 let totalTokens = 0
16 let totalMs = 0
17
18 on('turn.step', async function* ($, e, next) {
19 const sentAt = performance.now()
20 let firstAt = 0
21 let lastDraw = 0
22 let estimate = 0
23
24 const stream = next(e)
25 for await (const chunk of stream) {
26 const now = performance.now()
27 if (chunk.kind !== 'engine') {
28 if (firstAt === 0) firstAt = now
29 if (chunk.kind === 'text' || chunk.kind === 'thinking') estimate += estimateTokens(chunk.text)
30 else if (chunk.kind === 'input') estimate += estimateTokens(chunk.json)
31
32 if (chunk.kind === 'stop') {
33 const out = chunk.usage?.output_tokens ?? 0
34 const genMs = now - firstAt
35 if (out > 0 && genMs > 50) {
36 totalTokens += out
37 totalMs += genMs
38 const ttft = (firstAt - sentAt) / 1000
39 $.ui.status(
40 `${fmt(out / (genMs / 1000))} tps · TTFT ${ttft.toFixed(1)}s · total ${((now - sentAt) / 1000).toFixed(1)}s · avg ${fmt(totalTokens / (totalMs / 1000))} tps` +
41 (e.agentId ? ' (sub)' : ''),
42 )
43 }
44 } else if (now - lastDraw > 500 && now - firstAt > 300) {
45 lastDraw = now
46 $.ui.status(`~${fmt(estimate / ((now - firstAt) / 1000))} tps`)
47 }
48 }
49 yield chunk
50 }
51 })
52}
53