Shows output tokens per second on the line that closes each turn

A Claude Code mod that shows how fast Claude writes. The line under each reply gets its output speed in tokens per second:
✻ Worked (98 tok/s) for 5s · done 3:41 PM
The speed counts the time from sending each request to Claude until its reply ends, so the wait before the first token and the thinking are included. The time your tools spend running between requests is left out. Subagent work is not counted.
At the prompt of a Claude Code terminal session:
/plugin install tps-meter --marketplace Shaderzx/tps-meter
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install tps-meter --marketplace Shaderzx/tps-meter
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with Shaderzx/tps-meter, then Add plugin.
hooks/register.tsx 58 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const byDuration = atom({ plugin: 'tps-meter', key: 'byDuration' } as const, {})
5
6// Time counts from the moment each model request is sent until its reply ends: the wait for the first
7// token, thinking, retries and API-run tools included. Tool runs between requests are not the model's time.
8export const formatTps = (tokens: number, ms: number) =>
9 ms > 0 && tokens > 0 ? `${Math.round((tokens * 1000) / ms)} tok/s` : undefined
10
11// Only recent turns' closing lines are on screen; older entries would pile up in plugin state forever.
12const MAX_TURNS = 200
13export const keepLast = <T,>(m: Record<string, T>, n: number) =>
14 Object.fromEntries(Object.entries(m).slice(-n))
15
16export const register: Register = on => {
17 on('session.start', ($, e, next) => {
18 $.ui.status(undefined) // clears the status entry the first version left behind
19 return next(e)
20 })
21
22 const turns = new Map<string, { tokens: number; ms: number }>()
23
24 on('turn.step', async function* ($, e, next) {
25 if (e.agentId !== undefined) return yield* next(e) // main thread only
26
27 const start = await $.clock.now()
28 let end: number | undefined
29 const stream = next(e)
30 for await (const chunk of stream) {
31 if (chunk.kind === 'stop') end = await $.clock.now()
32 yield chunk
33 }
34 const result = await stream.result
35 if (end !== undefined) {
36 const t = turns.get(e.turnId) ?? { tokens: 0, ms: 0 }
37 turns.set(e.turnId, { tokens: t.tokens + (result.usage?.output_tokens ?? 0), ms: t.ms + end - start })
38 }
39 return result
40 })
41
42 on('turn.complete', async ($, e, next) => {
43 const t = turns.get(e.turnId)
44 turns.delete(e.turnId)
45 const text = e.agentId === undefined && t ? formatTps(t.tokens, t.ms) : undefined
46 // The closing line carries the same durationMs, so it is the key back to this turn.
47 if (text) await update($, byDuration, m => keepLast({ ...m, [String(e.durationMs)]: text }, MAX_TURNS))
48 return next(e)
49 })
50
51 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
52 const text = (await read($, byDuration))[String(e.props.durationMs)]
53 // The engine draws this line as one sealed block that fills the row, so anything placed beside it
54 // lands at the far right. Rewriting its word keeps it on the left: "Worked (64 tok/s) for 3s · done …".
55 return next(text ? { ...e, props: { ...e.props, word: `${e.props.word} (${text})` } } : e)
56 })
57}
58types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 // turn duration in ms -> "62 tok/s", the key the TurnDuration line also carries
4 'tps-meter': { byDuration: Record<string, string> }
5 }
6}
7
8export type TpsByDuration = Record<string, string>
9