Measures time to first token and output tokens/s of every model request, for your status line

English | 简体中文
Measures how fast Claude answers: time to first token (TTFT) and output tokens per second of every model request, written to a small JSON file your status line can show.
💬 12 | 🪙 ↑1.2M ↓35K | 💰 $1.2345 | ⚡ 121 tok/s · TTFT 1.1s · avg 129 tok/s
Claude Code's status line input carries no timing of the model's stream, so this mod hooks turn.step, the streaming event of each model request, and times the chunks as they arrive.
At the prompt of a terminal session:
/plugin install tps-meter --marketplace kadaliao/claude-mods
or from a shell:
claude plugin marketplace add kadaliao/claude-mods
claude plugin install tps-meter@kadaliao
After every main-loop model request the mod writes ~/.claude/tps/<session_id>.json ($CLAUDE_CONFIG_DIR/tps/ when that is set). A status line command reads it by the session_id it gets on stdin:
#!/bin/bash
input=$(cat)
session_id=$(jq -r '.session_id' <<<"$input")
tps_file="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/tps/$session_id.json"
speed=""
[[ -f $tps_file ]] && speed=$(jq -r '
def dur: if . >= 1000 then "\(. / 100 | round / 10)s" else "\(round)ms" end;
[ "⚡ \(if .last_tps then .last_tps | round else "-" end) tok/s",
"TTFT \(.ttft_ms | dur)",
(if .avg_tps then "avg \(.avg_tps | round) tok/s" else empty end)
] | join(" · ")' "$tps_file")
echo "$(jq -r '.model.display_name' <<<"$input")${speed:+ | $speed}"
Until the first request of a session ends there is no file, and the snippet shows nothing extra.
| Field | Meaning |
|---|---|
ttft_ms | Last request: from the moment it was sent to the first stream event (network, queueing and prefill) |
output_tokens, decode_ms | Last request: tokens generated, and the time from the first stream event to the end |
tps | Last request: output_tokens / decode_ms, thinking tokens included; null when it was too short to say |
last_tps | The latest tps that was not null: what to show |
avg_tps | The session's output tokens over its total decode time (token-weighted) |
avg_ttft_ms | The session's mean TTFT |
steps | Model requests counted in this session |
model, updated_at | Model of the last request, and when the file was written (epoch ms) |
A request that generates fewer than 50 tokens, or streams for less than 250 ms (a lone tool call), arrives in one burst, so its rate says nothing about the model's speed: its tps is null and last_tps keeps the previous value. It still counts toward avg_tps, where it weighs little.
Checked against Claude Code's own figures: the TTFT it measures is within a few milliseconds of the ttft_stream_ms that claude -p --output-format json reports, and output_tokens match.
~/.claude/tps/; nothing cleans them up.claude --plugin-dir ~/workspace/claude-mods/tps-meter # a session with the working copy loaded
claude plugin validate ~/workspace/claude-mods/tps-meter
claude plugin test ~/workspace/claude-mods/tps-meterhooks/register.ts 131 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Times every main-loop model request and writes the figures to
4// <config dir>/tps/<session id>.json, which statusline-command.sh reads.
5//
6// ttft_ms request sent -> first stream event (network + queue + prefill)
7// tps output_tokens / (stop - first event); thinking tokens count too
8//
9// A step that generates only a few tokens arrives in one burst, so its rate
10// is noise: `tps` is null for it and `last_tps` keeps the latest usable one.
11
12const MIN_TOKENS = 50
13const MIN_DECODE_MS = 250
14
15type Totals = { steps: number; outputTokens: number; decodeMs: number; ttftMs: number }
16
17type Snapshot = {
18 model: string
19 updated_at: number
20 ttft_ms: number
21 output_tokens: number
22 decode_ms: number
23 tps: number | null
24 last_tps: number | null
25 avg_tps: number | null
26 avg_ttft_ms: number
27 steps: number
28 session_output_tokens: number
29 session_decode_ms: number
30 session_ttft_ms: number
31}
32
33const emptyTotals = (): Totals => ({ steps: 0, outputTokens: 0, decodeMs: 0, ttftMs: 0 })
34
35// Module state starts over on a hot reload; restore() picks the session's
36// totals back up from the file the previous load wrote.
37let sessionId: string | undefined
38let totals = emptyTotals()
39let lastTps: number | null = null
40let dir: string | undefined
41
42const round1 = (n: number) => Math.round(n * 10) / 10
43
44async function metricsDir($: EngineInterface): Promise<string> {
45 if (dir === undefined) {
46 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
47 dir = `${config}/tps`
48 }
49 return dir
50}
51
52async function restore($: EngineInterface, path: string): Promise<void> {
53 totals = emptyTotals()
54 lastTps = null
55 try {
56 const prev = JSON.parse(String(await $.fs.read(path))) as Partial<Snapshot>
57 totals = {
58 steps: prev.steps ?? 0,
59 outputTokens: prev.session_output_tokens ?? 0,
60 decodeMs: prev.session_decode_ms ?? 0,
61 ttftMs: prev.session_ttft_ms ?? 0,
62 }
63 lastTps = prev.last_tps ?? null
64 } catch {
65 // no file yet: a new session
66 }
67}
68
69async function record(
70 $: EngineInterface,
71 model: string,
72 ttftMs: number,
73 decodeMs: number,
74 outputTokens: number,
75): Promise<void> {
76 const id = await $.session.id()
77 const path = `${await metricsDir($)}/${id}.json`
78 if (id !== sessionId) {
79 sessionId = id
80 await restore($, path)
81 }
82
83 totals.steps += 1
84 totals.outputTokens += outputTokens
85 totals.decodeMs += decodeMs
86 totals.ttftMs += ttftMs
87
88 const tps = outputTokens >= MIN_TOKENS && decodeMs >= MIN_DECODE_MS ? round1((outputTokens / decodeMs) * 1000) : null
89 if (tps !== null) lastTps = tps
90
91 const snapshot: Snapshot = {
92 model,
93 updated_at: Date.now(),
94 ttft_ms: Math.round(ttftMs),
95 output_tokens: outputTokens,
96 decode_ms: Math.round(decodeMs),
97 tps,
98 last_tps: lastTps,
99 avg_tps: totals.decodeMs > 0 ? round1((totals.outputTokens / totals.decodeMs) * 1000) : null,
100 avg_ttft_ms: Math.round(totals.ttftMs / totals.steps),
101 steps: totals.steps,
102 session_output_tokens: totals.outputTokens,
103 session_decode_ms: Math.round(totals.decodeMs),
104 session_ttft_ms: Math.round(totals.ttftMs),
105 }
106 await $.fs.write(path, JSON.stringify(snapshot))
107}
108
109export const register: Register = (on) => {
110 on('turn.step', async function* ($, e, next) {
111 // Subagents run in parallel with the main loop; only the main loop's
112 // requests are what the status line describes.
113 if (e.agentId !== undefined) return yield* next(e)
114
115 const sentAt = performance.now()
116 const stream = next(e)
117 let firstAt: number | undefined
118 for await (const chunk of stream) {
119 const now = performance.now()
120 firstAt ??= now
121 if (chunk.kind === 'stop' && chunk.usage) {
122 // Written before the stop chunk is passed on, so the status line
123 // refresh that the engine's usage update triggers already reads it.
124 await record($, e.model, firstAt - sentAt, now - firstAt, chunk.usage.output_tokens).catch(() => {})
125 }
126 yield chunk
127 }
128 return await stream.result
129 })
130}
131