SLOPSHOPPER

usage-tracker

Bottom-grid row: plan and session/week usage. Toasts when limits near.

newspinnertoastprocess
★ 1v0.9.5no licenseupdated 2026-10-03Lunik/gmz-claude-marketplace/usage-tracker
A shopper browsing a rack in a slop shop
README

usage-tracker

Shows your Claude subscription plan and how much of the 5-hour (session) and 7-day (week) usage windows you have used.

Claude Pro ┃ session ▰▰▰▱▱▱▱▱▱▱ 32% ┃ week ▰▰▰▰▰▰▰▰▱▱ 82%

Behavior

  • Row in the bottom-right grid. Bars have 10 cells, colored green below 80%, orange from 80%, red from 90%.
  • Toast when a window first crosses 80% and again at 90%. The toast rearms after the window resets.
  • The plan label comes from claude auth status (Claude Pro, Claude Max, ...), Claude if unknown.
  • session refreshes after every model request, week and the plan after every turn.
  • Shows - and empty bars until data is available.

Startup data

Rate limits normally arrive with the first API response. To show them earlier, the plugin queries https://api.anthropic.com/api/oauth/usage at session start and on the first render:

  • The OAuth token is read from the macOS keychain (Claude Code-credentials) inside a child python3 process, so it never appears in a command line, and is sent only to api.anthropic.com.
  • Values from this call only fill empty windows. The engine's own figures replace them once a response arrives.
  • On other platforms, or if the lookup fails, the bars stay at - until the first response.

Requirements

claude on PATH (plan lookup). macOS and python3 for the startup fetch.

Source 1 files
hooks/register.tsx 153 lines
1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3const LABELS: Record<string, string> = { five_hour: 'session', seven_day: 'week' }
4const USAGE_STEPS = [80, 90]
5const WIDTH = 10
6
7// highest step crossed, 0 if none
8export const step = (steps: number[], pct: number) => steps.filter(s => pct >= s).pop() ?? 0
9
10export const color = (pct: number, [warn, bad]: number[]) =>
11  pct >= bad ? 'error' : pct >= warn ? 'warning' : 'success'
12
13export const cells = (pct: number) =>
14  Math.round((Math.min(Math.max(pct, 0), 100) / 100) * WIDTH)
15
16export const shown = (limits: SessionRateLimit[]) => limits.filter(l => l.kind in LABELS)
17
18// `claude auth status` json -> "Claude Pro"; falls back to plain "Claude"
19export const planLabel = (stdout: string) => {
20  try {
21    const t = JSON.parse(stdout).subscriptionType
22    return typeof t === 'string' && t ? `Claude ${t[0].toUpperCase()}${t.slice(1)}` : 'Claude'
23  } catch {
24    return 'Claude'
25  }
26}
27
28// OAuth usage endpoint -> windows, so session/week show before the first API response.
29// Token is read inside the child process (macOS keychain) and never reaches argv.
30const FETCH = `
31import sys,json,subprocess,urllib.request
32t=json.loads(subprocess.check_output(['security','find-generic-password','-s','Claude Code-credentials','-w']))['claudeAiOauth']['accessToken']
33r=urllib.request.Request('https://api.anthropic.com/api/oauth/usage',headers={'Authorization':'Bearer '+t,'anthropic-beta':'oauth-2025-04-20'})
34sys.stdout.write(urllib.request.urlopen(r,timeout=10).read().decode())
35`
36
37export const parseUsage = (stdout: string): SessionRateLimit[] => {
38  try {
39    const j = JSON.parse(stdout)
40    return Object.keys(LABELS).flatMap(kind =>
41      typeof j[kind]?.utilization === 'number'
42        ? [{ kind, percentUsed: Math.round(j[kind].utilization * 10) / 10, resetsAt: j[kind].resets_at }]
43        : [],
44    )
45  } catch {
46    return []
47  }
48}
49
50// ponytail: macOS keychain only; elsewhere or on failure the bar waits for the first response
51async function seed($: EngineInterface, snap: Record<string, SessionRateLimit>) {
52  const r = await $.process.run(['python3', '-c', FETCH], { timeoutMs: 15_000 }).catch(() => null)
53  for (const l of parseUsage(r?.stdout ?? '')) snap[l.kind] ??= l
54  $.ui.invalidate('ui.render')
55}
56
57async function take($: EngineInterface, snap: Record<string, SessionRateLimit>, kind: string) {
58  const l = (await $.session.usage()).rateLimits.find(r => r.kind === kind)
59  if (l) snap[kind] = l
60  $.ui.invalidate('ui.render')
61}
62
63// ponytail: a failed lookup keeps the last known plan
64async function planOf($: EngineInterface, prev: string) {
65  const r = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 15_000 }).catch(() => null)
66  const p = planLabel(r?.stdout ?? '')
67  return p === 'Claude' ? prev : p
68}
69
70export const register: Register = (on, options) => {
71  // ponytail: module vars reset on hot reload; worst case a repeat toast
72  const warned: Record<string, number> = {}
73  // what the bar shows: session refreshed every step, week and plan every turn
74  const snap: Record<string, SessionRateLimit> = {}
75  let plan = 'Claude'
76  // ponytail: after a hot reload session.start does not fire; the first render loads what it would have
77  let primed = false
78
79  on('session.start', async ($, e, next) => {
80    await Promise.all([take($, snap, 'five_hour'), take($, snap, 'seven_day')]).catch(() => {})
81    await seed($, snap)
82    plan = await planOf($, plan)
83    $.ui.invalidate('ui.render')
84    return next(e)
85  })
86
87  on('turn.step', async function* ($, e, next) {
88    const res = yield* next(e)
89    if (!e.agentId) await take($, snap, 'five_hour').catch(() => {})
90    return res
91  })
92
93  on('turn.complete', async ($, e, next) => {
94    if (!e.agentId) {
95      await take($, snap, 'seven_day').catch(() => {})
96      plan = await planOf($, plan)
97      $.ui.invalidate('ui.render')
98    }
99    return next(e)
100  })
101
102  on('session.measure', async ($, e, next) => {
103    for (const l of shown(e.rateLimits)) {
104      const s = step(USAGE_STEPS, l.percentUsed)
105      if (s > (warned[l.kind] ?? 0)) {
106        $.ui.toast(`Claude ${LABELS[l.kind]} usage at ${Math.round(l.percentUsed)}%`)
107      }
108      warned[l.kind] = s // also resets after window reset
109    }
110
111    return next(e)
112  })
113
114  // bottom right, beside the prompt footer's mode labels
115  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
116    if (!primed) {
117      primed = true
118      void Promise.all([take($, snap, 'five_hour'), take($, snap, 'seven_day'), planOf($, plan).then(p => (plan = p))])
119        .then(() => seed($, snap))
120        .catch(() => {})
121    }
122    const limits = Object.keys(LABELS).map(k => [k, snap[k]] as const)
123    const other = await next(e)
124
125    const { Box, Text } = $.ui.resolve(e)
126    const bar = (
127      <Box>
128        <Box width={11}><Text bold color="#7dcfff">{plan}</Text></Box>
129        {limits.map(([kind, l], n) => (
130          <Box key={kind}>
131            <Text color="#565f89"> ┃ </Text>
132            <Box width={n < limits.length - 1 ? 24 : undefined}>
133              <Text dimColor>{LABELS[kind]} </Text>
134              {Array.from({ length: WIDTH }, (_, i) => (
135                <Text key={i} color={l && i < cells(l.percentUsed) ? color((i + 1) * (100 / WIDTH), USAGE_STEPS) : '#3b4261'}>
136                  {l && i < cells(l.percentUsed) ? '▰' : '▱'}
137                </Text>
138              ))}
139              <Text bold color={l ? color(l.percentUsed, USAGE_STEPS) : '#565f89'}> {l ? `${Math.round(l.percentUsed)}%` : '-'}</Text>
140            </Box>
141          </Box>
142        ))}
143      </Box>
144    )
145    return other.type === 'engine' ? bar : (
146      <Box flexDirection="column" alignItems="flex-start">
147        {other}
148        {bar}
149      </Box>
150    )
151  })
152}
153