SLOPSHOPPER

Tach

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

bandcommandtimer
v0.1.1MITupdated 2026-10-11DJPalefaceSD/rostech-mods/plugins/tach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tach
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tach ⎿ tach: ⚙️ 1.6k tokens a minute over the last 5m. Your busiest: 1.6k a minute. ⚙️ ▮▮▮▮▮▮▮▮▮▮ 1.6k/min 5m ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚙️ ▮▮▮▮▮▮▮▮▮▮ 1.6k/min 5m ⟨Claude Code's own drawing⟩
README

Tach

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.

  • Green is easy going. Amber is the upper half. The top two segments are the red zone.
  • The red zone is measured against your own busiest rate, which Tach remembers across sessions. It is your redline, not someone else's.
  • /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).

What it runs and sends

Nothing goes anywhere. Here is each thing, plainly.

What it sends, and where.

  • Nothing. It reads each turn's token counts from Claude Code when the turn ends, and shows a rate on your screen.
  • It keeps two numbers on this machine, in Claude Code's plugin store: your window (windowMin) and your busiest rate (peak).

Which programs it runs, and why.

  • None. It runs no programs.
  • Every 15 seconds it reads the clock, so the bar slides back while you are idle. That is all the timer does.

What it puts in the prompts it submits.

  • It submits no prompts, and changes none of yours.

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.

More mods

See every Rostech mod, live, at rostech.app.

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