SLOPSHOPPER

usage

Live session/weekly usage meters, reset times and a per-turn token sparkline above the prompt

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage
› 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 ◔ session 31% ↻ 09:53 █ ▲ +7.9k last turn ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ session 31% ↻ 09:53 █ ▲ +7.9k last turn
README

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> All Contributors <!-- ALL-CONTRIBUTORS-BADGE:END -->

usage — a Claude Code mod

Live view of your Claude plan usage in the band above the prompt, updated after every turn:

◔ session [  42%    ] ↻ 15:30  weekly [ 18%      ] ↻ Thu 09:00  ▂▃▅█▁▂  ▲ +98.3k last turn
  • Session (5h) and weekly meters, colored green → yellow → red, the percentage drawn in black or white for contrast
  • Local reset times: time of day for the session, weekday and time for the week
  • Sparkline of the tokens spent in the last 12 turns
  • Tokens the last turn added (input, cache writes and output, subagents included; cache reads left out)

Meters show only on a Claude subscription; with an API key you get the sparkline and turn total.

Prerequisites

Claude Code v2.1.287 or later (tested with 2.1.292).

Installation

In Claude Code:

/plugin install usage --marketplace technologiestiftung/claude-code-usage-mod

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

Development

Load the working copy instead of the installed one; it hot-reloads on save:

claude --plugin-dir .

The code is in hooks/register.tsx. Check it with:

claude plugin validate .

Tests

claude plugin test .

Contributing

Before you create a pull request, write an issue so we can discuss your changes.

Contributors

Thanks goes to these wonderful people (emoji key):

<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> <!-- prettier-ignore-start --> <!-- markdownlint-disable --> <table> <tbody> <tr> <td align="center" valign="top" width="14.28%"><a href="https://github.com/raphael-arce"><img src="https://avatars.githubusercontent.com/u/8709861?v=4?s=64" width="64px;" alt="Rapha"/><br /><sub><b>Rapha</b></sub></a><br /><a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Code">💻</a> <a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Documentation">📖</a> <a href="https://github.com/technologiestiftung/claude-code-usage-mod/commits?author=raphael-arce" title="Tests">⚠️</a> <a href="#ideas-raphael-arce" title="Ideas, Planning, & Feedback">🤔</a></td> </tr> </tbody> </table>

<!-- markdownlint-restore --> <!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the all-contributors specification. Contributions of any kind welcome!

Content Licensing

Texts and content available as CC BY.

Credits

<table> <tr> <td> Made by <a href="https://citylab-berlin.org/de/start/"> <img width="140" src="https://logos.citylab-berlin.org/logo-citylab-color.svg" alt="Link to the CityLAB Berlin website" /> </a> </td> <td> A project by <a href="https://www.technologiestiftung-berlin.de/"> <img width="150" src="https://logos.citylab-berlin.org/logo-technologiestiftung-berlin-de.svg" alt="Link to the Technologiestiftung Berlin website" /> </a> </td> <td> Supported by <a href="https://www.berlin.de/rbmskzl/"> <img width="80" src="https://logos.citylab-berlin.org/logo-berlin-senatskanzelei-de.svg" alt="Link to the Senate Chancellery of Berlin"/> </a> </td> </tr> </table>

Related Projects

Source 2 files
hooks/register.tsx 147 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit, TurnUsage } from 'claude-code'
3import type { TurnTokens } from '../types'
4
5const HISTORY = 12
6const METER_WIDTH = 10
7const BARS = '▁▂▃▄▅▆▇█'
8const TRACK = '#4a4a4a'
9
10// Token totals of the last turns, oldest first; kept across reloads
11const turns = atom({ plugin: 'usage', key: 'turns' } as const, [] as TurnTokens)
12
13// Tokens a turn spent: everything but cache reads, which re-count the whole context every step
14export const tokensOf = (u: TurnUsage | undefined) =>
15  u ? u.input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
16
17type Rgb = [number, number, number]
18const STOPS: Rgb[] = [
19  [34, 197, 94], // green
20  [234, 179, 8], // yellow
21  [239, 68, 68], // red
22]
23
24export function meterColor(pct: number): Rgb {
25  const t = Math.min(Math.max(pct, 0), 100) / 50
26  const i = Math.min(Math.floor(t), 1)
27  const f = t - i
28  const [a, b] = [STOPS[i], STOPS[i + 1]]
29  return [0, 1, 2].map(k => Math.round(a[k] + (b[k] - a[k]) * f)) as Rgb
30}
31
32const hex = (c: Rgb) => '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
33
34const luminance = (c: Rgb) => {
35  const [r, g, b] = c.map(v => {
36    const s = v / 255
37    return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
38  })
39  return 0.2126 * r + 0.7152 * g + 0.0722 * b
40}
41
42// Black or white, whichever contrasts more with the background (WCAG ratio)
43export function textOn(bg: Rgb) {
44  const l = luminance(bg)
45  return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) ? '#000000' : '#ffffff'
46}
47
48const rgbOf = (h: string): Rgb => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)) as Rgb
49
50// One run of cells per background, the percentage centred over the bar
51export function meterRuns(pct: number, width = METER_WIDTH) {
52  const label = `${Math.round(pct)}%`
53  const start = Math.floor((width - label.length) / 2)
54  const cells = Array.from({ length: width }, (_, i) => label[i - start] ?? ' ')
55  const filled = Math.min(width, Math.round((Math.min(pct, 100) / 100) * width))
56  const fill = hex(meterColor(pct))
57  const runs: { text: string; bg: string; fg: string }[] = []
58  cells.forEach((ch, i) => {
59    const bg = i < filled ? fill : TRACK
60    const last = runs[runs.length - 1]
61    if (last && last.bg === bg) last.text += ch
62    else runs.push({ text: ch, bg, fg: textOn(rgbOf(bg)) })
63  })
64  return runs
65}
66
67export function sparkline(values: number[]) {
68  const max = Math.max(...values, 1)
69  return values.map(v => BARS[Math.min(BARS.length - 1, Math.floor((v / max) * (BARS.length - 1)))]).join('')
70}
71
72export function compact(n: number) {
73  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
74  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
75  return String(n)
76}
77
78const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
79
80// Local reset time: "15:30" for the session window, "Thu 15:30" for the week
81export function resetAt(resetsAt: string, withDay: boolean) {
82  const d = new Date(resetsAt)
83  const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
84  return withDay ? `${DAYS[d.getDay()]} ${time}` : time
85}
86
87export const register: Register = on => {
88  // Subagent tokens spent since the last main-loop turn ended
89  let pending = 0
90
91  on('turn.complete', async ($, e, next) => {
92    pending += tokensOf(e.usage)
93    if (!e.agentId) {
94      const spent = pending
95      pending = 0
96      await update($, turns, list => [...list, spent].slice(-HISTORY))
97    }
98    return next(e)
99  })
100
101  on('session.measure', ($, e, next) => {
102    $.ui.invalidate('ui.render')
103    return next(e)
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    if (e.props.hasSurvey) return next(e)
108
109    const history = await read($, turns)
110    const { rateLimits } = await $.session.usage()
111    const find = (kind: string): SessionRateLimit | undefined => rateLimits.find(r => r.kind === kind)
112    const meters = [
113      { label: 'session', limit: find('five_hour'), withDay: false },
114      { label: 'weekly', limit: find('seven_day'), withDay: true },
115    ].filter(m => m.limit)
116
117    if (meters.length === 0 && history.length === 0) return next(e)
118
119    const { Box, Text } = $.ui.resolve(e)
120    const lastTurn = history[history.length - 1]
121
122    return (
123      <Box flexDirection="row">
124        <Text color="claude">◔ </Text>
125        {meters.map(({ label, limit, withDay }) => (
126          <Box key={label} flexDirection="row">
127            <Text dimColor>{label} </Text>
128            {meterRuns(limit!.percentUsed).map((r, i) => (
129              <Text key={String(i)} backgroundColor={r.bg} color={r.fg} bold>
130                {r.text}
131              </Text>
132            ))}
133            {limit!.resetsAt ? <Text dimColor> ↻ {resetAt(limit!.resetsAt, withDay)}</Text> : null}
134            <Text>{'  '}</Text>
135          </Box>
136        ))}
137        {history.length > 0 ? (
138          <Text>
139            <Text color="suggestion">{sparkline(history)}</Text>
140            <Text dimColor>{'  '}▲ +{compact(lastTurn)} last turn</Text>
141          </Text>
142        ) : null}
143      </Box>
144    )
145  })
146}
147
types/index.d.ts 8 lines
1export type TurnTokens = number[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    usage: { turns: TurnTokens }
6  }
7}
8