SLOPSHOPPER

tps-meter

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

spinnerstatus
v0.2.0no licenseupdated 2026-10-18Shaderzx/tps-meter
A shopper browsing a rack in a slop shop
README

tps-meter

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.

Install

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.

Source 2 files
hooks/register.tsx 58 lines
1import { 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}
58
types/index.d.ts 9 lines
1declare 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