SLOPSHOPPER

cost-meter

Band showing token usage and cache hit ratio per turn and per session.

newbandstatustimer
v0.1.0MITupdated 2026-10-26namtrdev/claude-mods/cost-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-meter
› 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 USAGE turn in 97.4k out 1.5k cache 93% session in 97.4k · out 1.5k · cache 93% cache warm 5:00 Cache TTL: [ ● 5 min ] [ ○ 1 hour ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
USAGE turn in 97.4k out 1.5k cache 93% session in 97.4k · out 1.5k · cache 93% cache warm 5:00 Cache TTL: [ ● 5 min ] [ ○ 1 hour ]
README

Cost Meter

A band above the prompt that shows token usage for the last turn and the whole session, including the prompt-cache hit ratio.

Install

/plugin marketplace add namtrdev/claude-mods
/plugin install cost-meter@jackie-mods

Use

Commands: shows after the first reply.

Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.

Options

You set these when you install the mod, and can change them later in the Claude Code config menu.

  • Default cache TTL (defaultTtl, default 5m): Which prompt-cache lifetime the countdown assumes until you press a TTL button.
  • Show the cache countdown (showCacheClock, default true): Show the second row with the cache clock and the TTL buttons.

License

MIT, Copyright (c) 2026 Jackie.

Icons: Lucide (ISC). See THIRD_PARTY_NOTICES.md.

Source 3 files
hooks/register.tsx 117 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { icon } from './icons'
5
6import type { TurnUse } from '../types'
7
8const last = atom({ plugin: 'cost-meter', key: 'last' } as const, null)
9const ttl = atom({ plugin: 'cost-meter', key: 'ttl' } as const, '5m')
10const lastAt = atom({ plugin: 'cost-meter', key: 'lastAt' } as const, 0)
11const total = atom({ plugin: 'cost-meter', key: 'total' } as const, { input: 0, output: 0, read: 0, write: 0 })
12
13const NAME = { '5m': '5 min', '1h': '1 hour' }
14const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
15const clock = (ms: number) => {
16  const s = Math.max(0, Math.ceil(ms / 1000))
17  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
18}
19
20const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
21const hit = (u: TurnUse) => {
22  const all = u.input + u.read + u.write
23  return all === 0 ? 0 : Math.round((u.read / all) * 100)
24}
25
26export const register: Register = (on, options) => {
27  let timer: { cancel: () => void } | undefined
28
29  on('session.start', async ($, e, next) => {
30    if ((await read($, lastAt)) === 0) {
31      await update($, ttl, () => (options.defaultTtl === '1h' ? '1h' : '5m'))
32    }
33    timer?.cancel()
34    timer = $.clock.every(1000, async () => {
35      const at = await read($, lastAt)
36      if (at === 0 || options.showCacheClock === false) return
37      const left = at + TTL_MS[await read($, ttl)] - (await $.clock.now())
38      $.ui.status(left > 0 ? `cache warm ${clock(left)}` : 'cache cold')
39      $.ui.invalidate('ui.render')
40    })
41
42    return next(e)
43  })
44
45  on('turn.complete', async ($, e, next) => {
46    if (e.usage !== undefined && e.agentId === undefined) {
47      const use: TurnUse = {
48        input: e.usage.input_tokens,
49        output: e.usage.output_tokens,
50        read: e.usage.cache_read_input_tokens,
51        write: e.usage.cache_creation_input_tokens,
52      }
53      await update($, last, () => use)
54      const now = await $.clock.now()
55      await update($, lastAt, () => now)
56      await update($, total, sum => ({
57        input: sum.input + use.input,
58        output: sum.output + use.output,
59        read: sum.read + use.read,
60        write: sum.write + use.write,
61      }))
62    }
63
64    return next(e)
65  })
66
67  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68    const turn = await read($, last)
69    if (e.props.hasSurvey || turn === null) {
70      return next(e)
71    }
72    const ui = $.ui.resolve(e)
73    const { Box, Button, Text } = ui
74    const sum = await read($, total)
75    const span = await read($, ttl)
76    const at = await read($, lastAt)
77    const left = at + TTL_MS[span] - (await $.clock.now())
78    const hasSvg = ui.Svg !== undefined
79
80    return (
81      <Box flexDirection="column" gap={1}>
82        <Box gap={2}>
83          <Box gap={1}>
84            {ui.Svg !== undefined && icon(ui, 'gauge', '#9ca3af', '')}
85            <Text bold>USAGE</Text>
86          </Box>
87          <Text>
88            turn <Text color="#5fe3ff">in {k(turn.input + turn.read + turn.write)}</Text>{' '}
89            <Text color="#ffe066">out {k(turn.output)}</Text>{' '}
90            <Text color="#7dff9a">cache {hit(turn)}%</Text>
91          </Text>
92          <Text dimColor>
93            session in {k(sum.input + sum.read + sum.write)} · out {k(sum.output)} · cache {hit(sum)}%
94          </Text>
95        </Box>
96        {options.showCacheClock !== false && (
97        <Box gap={2}>
98          <Box gap={1}>
99            {hasSvg && icon(ui, 'timer', left > 0 ? '#7dff9a' : '#9ca3af', '')}
100            <Text color={left > 0 ? 'green' : undefined} dimColor={left <= 0}>
101              {left > 0 ? `cache warm ${clock(left)}` : 'cache cold'}
102            </Text>
103          </Box>
104          <Text dimColor>Cache TTL:</Text>
105          {(['5m', '1h'] as const).map(one => (
106            <Box gap={1}>
107              {hasSvg && icon(ui, span === one ? 'circle-dot' : 'circle', span === one ? '#7dff9a' : '#9ca3af', '', 12)}
108              <Button key={`ttl-${one}`} label={hasSvg ? NAME[one] : span === one ? `● ${NAME[one]}` : `○ ${NAME[one]}`} onPress={() => update($, ttl, () => one)} />
109            </Box>
110          ))}
111        </Box>
112        )}
113      </Box>
114    )
115  })
116}
117
hooks/icons.tsx 26 lines
1// Icons from Lucide (https://lucide.dev, ISC license), lucide-static 0.469.0.
2// Svg draws on the desktop, editor and mobile surfaces; the terminal has none, so a glyph stands in.
3const PATHS = {
4  'gauge': '<path d="m12 14 4-4" /> <path d="M3.34 19a10 10 0 1 1 17.32 0" />',
5  'circle': '<circle cx="12" cy="12" r="10" />',
6  'circle-dot': '<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="1" />',
7  'timer': '<line x1="10" x2="14" y1="2" y2="2" /> <line x1="12" x2="15" y1="14" y2="11" /> <circle cx="12" cy="14" r="8" />',
8}
9
10export type IconName = keyof typeof PATHS
11
12export const svg = (name: IconName, color: string, size: number) =>
13  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</svg>`
14
15// eslint-disable-next-line @typescript-eslint/no-explicit-any
16export type Ui = { Svg?: any; Text: any }
17
18export const icon = (ui: Ui, name: IconName, color: string, glyph: string, size = 16) => {
19  const { Svg, Text } = ui
20  return Svg === undefined ? (
21    <Text color={color}>{glyph}</Text>
22  ) : (
23    <Svg source={svg(name, color, size)} alt={name} width={size} height={size} />
24  )
25}
26
types/index.d.ts 9 lines
1export type TurnUse = { input: number; output: number; read: number; write: number }
2export type CacheTtl = '5m' | '1h'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'cost-meter': { last: TurnUse | null; total: TurnUse; ttl: CacheTtl; lastAt: number }
7  }
8}
9