SLOPSHOPPER

token-dashboard

A live TUI pane showing this session's token usage, cost, context fill and rate limits

panecommand
v0.1.0no licenseupdated 2026-10-18Mulbrix/claude-mods/plugins/token-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-dashboard
│ ┃ Tokens ✕ › fix the failing auth test and add an audit log call │ ┃ Token usage $0.42 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Total 0 over 0 requests ⎿ Read 6 lines │ ┃ Input 0 ⏺ Update(src/auth.ts) │ ┃ Output 0 ⎿ Added 2 lines, removed 1 line │ ┃ Cache read 0 ⏺ Bash(bun test) │ ┃ Cache write 0 ⎿ 3 pass, 1 fail │ ┃ Cache hit 0% │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Limits │ ┃ Context █████████████████░░░░░░░░░░░░░░ ✻ Worked for 42s · done 4:20 PM │ ┃ 97k/200k │ ┃ 5-hour ███████████░░░░░░░░░░░░░░░░░░░░ › /tokens │ ┃ resets in NaNh NaNm ⎿ token-dashboard: Token dashboard opened. │ ┃ │ ┃ Per request (last 0) │ ┃ No requests yet. │ ┃ │ ┃ r: Reset counters │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tokens
Token usage $0.42 Total 0 over 0 requests Input 0 Output 0 Cache read 0 Cache write 0 Cache hit 0% Limits Context █████████████████░░░░░░░░░░░░░░░░░ 49% 97k/200k 5-hour ███████████░░░░░░░░░░░░░░░░░░░░░░░ 31% resets i Per request (last 0) No requests yet. r: Reset counters
README

token-dashboard

A Claude Code mod: a live pane showing the session's token usage — totals, cache hit rate, cost, context window fill, rate limits, a per-request sparkline and a per-model breakdown.

Open it with /tokens.

Install

The repository is private: you need access to it on GitHub, and GitHub set up on your computer.

/plugin install token-dashboard --marketplace mulbrix/claude-mods

Answer y to add the marketplace, then pick a scope.

Source 2 files
hooks/register.tsx 212 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, TurnUsage } from 'claude-code'
3
4import type { Step, Tokens, Usage } from '../types'
5
6const PANE = 'token-dashboard'
7const MAX_STEPS = 500
8const SPARK = '▁▂▃▄▅▆▇█'
9
10const zero = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
11const empty = (): Usage => ({
12  total: zero(),
13  requests: 0,
14  byModel: {},
15  main: zero(),
16  agents: zero(),
17  steps: [],
18})
19
20const usage = atom({ plugin: 'token-dashboard', key: 'usage' } as const, empty())
21
22const add = <T extends Tokens>(a: T, b: Tokens): T => ({
23  ...a,
24  input: a.input + b.input,
25  output: a.output + b.output,
26  cacheRead: a.cacheRead + b.cacheRead,
27  cacheWrite: a.cacheWrite + b.cacheWrite,
28})
29
30const fromApi = (u: TurnUsage): Tokens => ({
31  input: u.input_tokens,
32  output: u.output_tokens,
33  cacheRead: u.cache_read_input_tokens,
34  cacheWrite: u.cache_creation_input_tokens,
35})
36
37const sum = (t: Tokens) => t.input + t.output + t.cacheRead + t.cacheWrite
38
39export const record = (prev: Usage, step: Step): Usage => {
40  const model = prev.byModel[step.model] ?? { ...zero(), requests: 0 }
41  return {
42    total: add(prev.total, step),
43    requests: prev.requests + 1,
44    byModel: { ...prev.byModel, [step.model]: { ...add(model, step), requests: model.requests + 1 } },
45    main: step.isAgent ? prev.main : add(prev.main, step),
46    agents: step.isAgent ? add(prev.agents, step) : prev.agents,
47    steps: [...prev.steps, step].slice(-MAX_STEPS),
48  }
49}
50
51export const fmt = (n: number): string => {
52  if (n < 1000) return String(n)
53  if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`
54  return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`
55}
56
57export const bar = (pct: number, width: number): string => {
58  const w = Math.max(4, width)
59  const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * w)
60  return '█'.repeat(filled) + '░'.repeat(w - filled)
61}
62
63export const sparkline = (values: number[]): string => {
64  const max = Math.max(1, ...values)
65  return values.map(v => SPARK[Math.min(SPARK.length - 1, Math.floor((v / max) * (SPARK.length - 1)))]).join('')
66}
67
68const pctColor = (pct: number) => (pct >= 85 ? 'error' : pct >= 60 ? 'warning' : 'success')
69
70const shortModel = (id: string) => id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
71
72const LIMIT_LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: '7-day', spend_limit: 'Spend' }
73
74const resetsIn = (iso: string | undefined, now: number) => {
75  if (!iso) return ''
76  const mins = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
77  return mins >= 60 * 24
78    ? `resets in ${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`
79    : `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    await $.command.register({
85      name: 'tokens',
86      description: 'Open the token usage dashboard',
87    })
88
89    return next(e)
90  })
91
92  on('command.run', { command: 'tokens' }, async $ => {
93    await $.ui.open({ id: PANE, title: 'Tokens', focus: true })
94
95    return { text: 'Token dashboard opened.' }
96  })
97
98  on('turn.step', async function* ($, e, next) {
99    const result = yield* next(e)
100    if (result.usage) {
101      const u = result.usage
102      const step: Step = { ...fromApi(u), at: await $.clock.now(), model: u.model, isAgent: e.agentId !== undefined }
103      await update($, usage, prev => record(prev, step))
104    }
105
106    return result
107  })
108
109  on('session.measure', async ($, e, next) => {
110    $.ui.invalidate('ui.render')
111
112    return next(e)
113  })
114
115  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116    const { Box, Text, Button } = $.ui.resolve(e)
117    const u = await read($, usage)
118    const session = await $.session.usage()
119    const now = await $.clock.now()
120    const cols = Math.max(30, e.props.bodyColumns)
121    const barWidth = Math.min(40, cols - 22)
122
123    const ctx = session.context
124    const inputSide = u.total.input + u.total.cacheRead + u.total.cacheWrite
125    const hitRate = inputSide === 0 ? 0 : Math.round((u.total.cacheRead / inputSide) * 100)
126    const spark = sparkline(u.steps.slice(-Math.max(8, cols - 4)).map(sum))
127    const models = Object.entries(u.byModel).sort((a, b) => sum(b[1]) - sum(a[1]))
128
129    const row = (label: string, value: string, color?: string) => (
130      <Box>
131        <Box width={12}><Text dimColor>{label}</Text></Box>
132        <Text color={color} bold>{value}</Text>
133      </Box>
134    )
135
136    return (
137      <Box flexDirection="column" paddingX={1}>
138        <Box justifyContent="space-between">
139          <Text bold color="claude">Token usage</Text>
140          <Text bold color="success">{session.cost ? `$${session.cost.usd.toFixed(2)}` : ''}</Text>
141        </Box>
142
143        <Box flexDirection="column" marginTop={1}>
144          {row('Total', `${fmt(sum(u.total))}  over ${u.requests} request${u.requests === 1 ? '' : 's'}`)}
145          {row('Input', fmt(u.total.input))}
146          {row('Output', fmt(u.total.output))}
147          {row('Cache read', fmt(u.total.cacheRead))}
148          {row('Cache write', fmt(u.total.cacheWrite))}
149          {row('Cache hit', `${hitRate}%`, hitRate >= 70 ? 'success' : hitRate >= 40 ? 'warning' : 'error')}
150          {u.agents.output + u.agents.input > 0 &&
151            row('Subagents', `${fmt(sum(u.agents))}  (main ${fmt(sum(u.main))})`)}
152        </Box>
153
154        <Box flexDirection="column" marginTop={1}>
155          <Text bold>Limits</Text>
156          <Box>
157            <Box width={12}><Text dimColor>Context</Text></Box>
158            {ctx.percent === undefined ? (
159              <Text dimColor>no response yet · {fmt(ctx.window)} window</Text>
160            ) : (
161              <Text>
162                <Text color={pctColor(ctx.percent)}>{bar(ctx.percent, barWidth)}</Text> {ctx.percent}%{' '}
163                <Text dimColor>{fmt(ctx.tokens ?? 0)}/{fmt(ctx.window)}</Text>
164              </Text>
165            )}
166          </Box>
167          {session.rateLimits.map(rl => (
168            <Box>
169              <Box width={12}><Text dimColor>{LIMIT_LABELS[rl.kind] ?? rl.kind}</Text></Box>
170              <Text>
171                <Text color={pctColor(rl.percentUsed)}>{bar(rl.percentUsed, barWidth)}</Text> {rl.percentUsed}%{' '}
172                <Text dimColor>{resetsIn(rl.resetsAt, now)}</Text>
173              </Text>
174            </Box>
175          ))}
176        </Box>
177
178        <Box flexDirection="column" marginTop={1}>
179          <Text bold>Per request <Text dimColor>(last {Math.min(u.steps.length, cols - 4)})</Text></Text>
180          {u.steps.length === 0 ? <Text dimColor>No requests yet.</Text> : <Text color="claude">{spark}</Text>}
181          {u.steps.length > 0 && (
182            <Text dimColor>
183              last {fmt(sum(u.steps[u.steps.length - 1] ?? zero()))} · peak {fmt(Math.max(...u.steps.map(sum)))} · avg{' '}
184              {fmt(Math.round(sum(u.total) / Math.max(1, u.requests)))}
185            </Text>
186          )}
187        </Box>
188
189        {models.length > 0 && (
190          <Box flexDirection="column" marginTop={1}>
191            <Text bold>By model</Text>
192            {models.map(([id, m]) => (
193              <Box>
194                <Box width={Math.min(24, cols - 30)}><Text>{shortModel(id)}</Text></Box>
195                <Text dimColor>
196                  in {fmt(m.input + m.cacheRead + m.cacheWrite).padStart(6)}  out {fmt(m.output).padStart(6)}  ×{m.requests}
197                </Text>
198              </Box>
199            ))}
200          </Box>
201        )}
202
203        <Box marginTop={1}>
204          <Button hotkey="r" plain dimColor onPress={async () => { await update($, usage, () => empty()) }}>
205            Reset counters
206          </Button>
207        </Box>
208      </Box>
209    )
210  })
211}
212
types/index.d.ts 19 lines
1export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
2
3export type Step = Tokens & { at: number; model: string; isAgent: boolean }
4
5export type Usage = {
6  total: Tokens
7  requests: number
8  byModel: Record<string, Tokens & { requests: number }>
9  main: Tokens
10  agents: Tokens
11  steps: Step[]
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'token-dashboard': { usage: Usage }
17  }
18}
19