A tachometer above the prompt: how fast you are burning tokens, over a window you choose.

A tachometer above the prompt. It shows how fast you are burning tokens right now, as tokens a minute over the last few minutes, on a ten-segment bar.
/tach prints the rate and your busiest rate./tach 15m or /tach 1h changes the window. The default is 5 minutes./tach hide and /tach show toggle the band.Tach counts fresh tokens: what each turn reads new, writes to the prompt cache, and generates. Cache reads are left out, because they are cheap and would swamp the needle. It updates when a turn ends, and slides back toward zero while you are idle. Nothing leaves your machine.
It pairs with Gas Gauge (how much plan is left), Speedometer (how full the context is) and Odometer (the session total).
Nothing goes anywhere. Here is each thing, plainly.
What it sends, and where.
windowMin) and your busiest rate (peak).Which programs it runs, and why.
What it puts in the prompts it submits.
What each hook does.
session.start: adds the /tach command, loads your window and busiest rate, and starts the 15-second clock.turn.complete: counts the turn's fresh tokens and saves a new busiest rate if there is one.command.run for tach: prints the rate, sets the window, or hides or shows the band.ui.render for AbovePrompt: draws the bar above the prompt.Tested with Claude Code 2.1.287.
See every Rostech mod, live, at rostech.app.
hooks/register.tsx 132 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { Burn } from '../types'
4
5// Every turn's tokens this session, with when it ended. Only the last day is kept.
6const burns = atom({ plugin: 'tach', key: 'burns' } as const, [] as Burn[])
7// The clock as the band last read it, so the window slides even while idle.
8const now = atom({ plugin: 'tach', key: 'now' } as const, 0)
9// How far back the reading looks, in minutes. $.store keeps it across sessions.
10const windowMin = atom({ plugin: 'tach', key: 'windowMin' } as const, 5)
11// The busiest rate ever read, tokens a minute. The red zone is measured from it.
12const peak = atom({ plugin: 'tach', key: 'peak' } as const, 0)
13const isHidden = atom({ plugin: 'tach', key: 'isHidden' } as const, false)
14
15const DAY = 24 * 60 * 60_000
16const SEGMENTS = 10
17// The top two segments are the red zone: 80% of your own busiest rate and up.
18const REDLINE = 8
19
20// Fresh tokens only: what the turn read new, wrote to the cache and generated.
21// Cache reads are left out; they are cheap and would swamp the needle.
22function tokensOf(u: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number }): number {
23 return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
24}
25
26function rate(list: readonly Burn[], at: number, minutes: number): number {
27 const from = at - minutes * 60_000
28 const sum = list.filter(b => b.at > from && b.at <= at).reduce((t, b) => t + b.tokens, 0)
29 return Math.round(sum / minutes)
30}
31
32function short(n: number): string {
33 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
34}
35
36function span(minutes: number): string {
37 return minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`
38}
39
40// "5m", "90m", "1h", "2h" into minutes; anything else is null.
41function parseSpan(text: string): number | null {
42 const m = /^(\d+)\s*(m|min|h|hr)?$/i.exec(text.trim())
43 if (!m) return null
44 const n = Number(m[1]) * (m[2] && m[2].toLowerCase().startsWith('h') ? 60 : 1)
45 return n >= 1 && n <= 24 * 60 ? n : null
46}
47
48// How many of the ten segments are lit for this rate.
49function lit(r: number, top: number): number {
50 return r <= 0 || top <= 0 ? 0 : Math.max(1, Math.min(SEGMENTS, Math.round((r / top) * SEGMENTS)))
51}
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 await $.command.register({ name: 'tach', description: 'How fast you are burning tokens. /tach, /tach 15m or /tach 1h for the window, /tach hide or show.' })
56 const keptWindow = await $.store.get('windowMin')
57 if (typeof keptWindow === 'number') await update($, windowMin, () => keptWindow)
58 const keptPeak = await $.store.get('peak')
59 if (typeof keptPeak === 'number') await update($, peak, () => keptPeak)
60 await update($, now, async () => await $.clock.now())
61 // Every 15 seconds the window slides, so an idle tach falls back to zero.
62 $.clock.every(15_000, async () => {
63 await update($, now, async () => await $.clock.now())
64 })
65 return next(e)
66 })
67
68 on('turn.complete', async ($, e, next) => {
69 const result = await next(e)
70 if (e.usage) {
71 const at = await $.clock.now()
72 const tokens = tokensOf(e.usage)
73 const list = [...(await read($, burns)).filter(b => b.at > at - DAY), { at, tokens }]
74 await update($, burns, () => list)
75 await update($, now, () => at)
76 const r = rate(list, at, await read($, windowMin))
77 if (r > (await read($, peak))) {
78 await update($, peak, () => r)
79 await $.store.set('peak', r)
80 }
81 }
82 return result
83 })
84
85 on('command.run', { command: 'tach' }, async ($, e) => {
86 const arg = e.args.trim().toLowerCase()
87 if (arg === 'hide' || arg === 'show') {
88 await update($, isHidden, () => arg === 'hide')
89 return { text: arg === 'hide' ? 'Tach hidden.' : 'Tach showing.' }
90 }
91 if (arg) {
92 const minutes = parseSpan(arg)
93 if (minutes === null) return { text: 'Give the window as minutes or hours: /tach 5m, /tach 30m, /tach 2h.' }
94 await update($, windowMin, () => minutes)
95 await $.store.set('windowMin', minutes)
96 return { text: `Tach now reads the last ${span(minutes)}.` }
97 }
98 const at = await $.clock.now()
99 const minutes = await read($, windowMin)
100 const r = rate(await read($, burns), at, minutes)
101 const top = await read($, peak)
102 return { text: `⚙️ ${short(r)} tokens a minute over the last ${span(minutes)}.${top ? ` Your busiest: ${short(top)} a minute.` : ''}` }
103 })
104
105 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
106 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
107 const list = await read($, burns)
108 if (!list.length) return next(e)
109 const minutes = await read($, windowMin)
110 const r = rate(list, await read($, now), minutes)
111 const glowing = lit(r, await read($, peak))
112
113 const { Box, Text } = $.ui.resolve(e)
114 // Green, then amber from the middle, then red in the top two segments.
115 const segment = (i: number) => (i >= REDLINE ? 'red' : i >= SEGMENTS / 2 ? 'yellow' : 'green')
116 const below = await next(e)
117 return (
118 <Box flexDirection="row">
119 <Box marginRight={3} flexDirection="row" flexShrink={0}>
120 <Text>⚙️ </Text>
121 {Array.from({ length: SEGMENTS }, (_, i) =>
122 i < glowing ? <Text key={i} color={segment(i)}>▮</Text> : <Text key={i} dimColor>▯</Text>
123 )}
124 <Text bold> {short(r)}/min</Text>
125 <Text dimColor> {span(minutes)}</Text>
126 </Box>
127 {below}
128 </Box>
129 )
130 })
131}
132types/index.d.ts 9 lines1// One turn's tokens, and when the turn ended (ms since the epoch).
2export type Burn = { at: number; tokens: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'tach': { burns: Burn[]; now: number; windowMin: number; peak: number; isHidden: boolean }
7 }
8}
9