SLOPSHOPPER

Usage Tally

Your Claude plan usage inside Claude Code: session (5h) and weekly limits with reset countdowns in the status line, a /tally pane with bars, and warnings at…

newpanecommandtoaststatustimer
★ 2v0.1.0MITupdated 2026-10-08Ankitrai97/rai-claude-mods/plugins/usage-tally
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-tally
│ ┃ Usage Tally ✕ › fix the failing auth test and add an audit log call │ ┃ Session (5h) 31% │ ┃ █████████░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ reset time not reported ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ This chat ⏺ Bash(bun test) │ ┃ cost $0.42 · context 49% ⎿ 3 pass, 1 fail │ ┃ │ ┃ Read just now from Claude's last reply. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Limits count claude.ai and Claude Code │ ┃ together. ✻ Worked for 42s · done 4:20 PM │ │ › /tally │ ⎿ usage-tally: Usage Tally: Session 31% │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-tally: Session 31%

Draws

Pane · Usage Tally
Session (5h) 31% █████████░░░░░░░░░░░░░░░░░░░░░ reset time not reported This chat cost $0.42 · context 49% Read just now from Claude's last reply. Limits count claude.ai and Claude Code together.
README

usage-tally

Your Claude plan usage inside Claude Code, like the Tally browser extension shows on claude.ai.

  • Status line: Session 42% · resets 2h 10m · Week 19%, refreshed after every reply and its countdown every minute
  • /tally: opens a pane with a coloured bar for each limit (green under 75%, amber 75–90%, red 90%+), its reset countdown, and this chat's cost and context fill
  • Warnings: a toast when a limit crosses 75% and again at 90%, once per limit window, remembered across chats

The figures are the ones Claude's own replies report, so they update when Claude answers, not while you are idle. The limits count claude.ai and Claude Code together. Off a Claude subscription (API key) there are no limit figures, only cost and context.

What it reaches

NetworkRuns processesFilesCalls a modelSends data anywhere
NoNoNoNoNo

It keeps one list in the plugin store: which warnings it has already shown. Run claude plugin validate ./usage-tally to list every event it hooks and every call it makes.

Source 3 files
hooks/register.tsx 147 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Reading } from '../types'
5import { bar, dueWarnings, labelOf, levelOf, pct, resetIn, statusLine } from './meter'
6
7const PANE = 'usage-tally'
8const TITLE = 'Usage Tally'
9/** $.store key: warnings already shown, so a new chat does not repeat them. */
10const SHOWN_KEY = 'shownWarnings'
11const TICK_MS = 60_000
12
13const EMPTY: Reading = { limits: [], costUsd: null, contextPercent: null, at: null }
14const reading = atom({ plugin: 'usage-tally', key: 'reading' } as const, EMPTY)
15const nowAtom = atom({ plugin: 'usage-tally', key: 'now' } as const, 0)
16
17const GREEN = '#4ade80'
18const AMBER = '#fbbf24'
19const RED = '#f87171'
20const COLOR = { ok: GREEN, warn: AMBER, high: RED } as const
21
22const toLimits = (list: readonly SessionRateLimit[]): Limit[] =>
23  list.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) }))
24
25const refreshStatus = async ($: EngineInterface) => {
26  const r = await read($, reading)
27  $.ui.status(statusLine(r.limits, await $.clock.now()))
28}
29
30const warn = async ($: EngineInterface, limits: readonly Limit[]) => {
31  const shown = ((await $.store.get(SHOWN_KEY)) as string[] | undefined) ?? []
32  const due = dueWarnings(limits, shown)
33  if (due.length === 0) return
34  const now = await $.clock.now()
35  for (const d of due) {
36    const limit = limits.find(l => d.key.startsWith(`${l.kind}@`))
37    const reset = limit ? resetIn(limit, now) : null
38    $.ui.toast(`${d.text}${reset ? `, resets in ${reset}` : ''}.`, { timeoutMs: 10_000 })
39  }
40  // Keep the last 40 keys: enough for every window's thresholds, small in the store.
41  await $.store.set(SHOWN_KEY, [...shown, ...due.map(d => d.key)].slice(-40))
42}
43
44const take = async (
45  $: EngineInterface,
46  figures: { rateLimits: readonly SessionRateLimit[]; cost?: { usd: number }; context: { percent?: number } },
47) => {
48  const limits = toLimits(figures.rateLimits)
49  const now = await $.clock.now()
50  await update($, reading, prev => ({
51    // A measure with no rate-limit reading keeps the last one rather than blanking the meter.
52    limits: limits.length ? limits : prev.limits,
53    costUsd: figures.cost?.usd ?? prev.costUsd,
54    contextPercent: figures.context.percent ?? prev.contextPercent,
55    at: limits.length ? now : prev.at,
56  }))
57  await update($, nowAtom, () => now)
58  await refreshStatus($)
59  if (limits.length) await warn($, limits)
60}
61
62export const register: Register = on => {
63  on('session.start', async ($, e, next) => {
64    await $.command.register({ name: 'tally', description: 'Usage Tally: your session and weekly limits, with reset times' })
65    const ran = await next(e)
66    try {
67      await take($, await $.session.usage())
68    } catch {
69      // No figures yet; the first reply's measure fills them in.
70    }
71    $.clock.every(TICK_MS, () => {
72      void (async () => {
73        const now = await $.clock.now()
74        await update($, nowAtom, () => now)
75        await refreshStatus($)
76      })().catch(() => undefined)
77    })
78    return ran
79  })
80
81  on('session.measure', async ($, e, next) => {
82    await take($, e)
83    return next(e)
84  }).catch(($, e, next) => next(e))
85
86  on('command.run', { command: 'tally' }, async $ => {
87    try {
88      await take($, await $.session.usage())
89    } catch {
90      // Keep the last reading.
91    }
92    await $.ui.open({ id: PANE, title: TITLE })
93    const r = await read($, reading)
94    const line = statusLine(r.limits, await $.clock.now())
95    return { text: line ? `Usage Tally: ${line}` : 'Usage Tally is open. Limits show after Claude\'s next reply (Claude subscription only).' }
96  })
97
98  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99    const { Box, Text } = $.ui.resolve(e)
100    const r = await read($, reading)
101    const now = (await read($, nowAtom)) || (await $.clock.now())
102    const width = Math.max(10, Math.min(30, (e.props.bodyColumns ?? 40) - 14))
103
104    const row = (l: Limit) => {
105      const level = levelOf(l.percentUsed)
106      const reset = resetIn(l, now)
107      return (
108        <Box key={l.kind} flexDirection="column" marginBottom={1}>
109          <Box justifyContent="space-between">
110            <Text bold>{labelOf(l.kind)}</Text>
111            <Text color={COLOR[level]} bold>{pct(l.percentUsed)}</Text>
112          </Box>
113          <Text color={COLOR[level]}>{bar(l.percentUsed, width)}</Text>
114          <Text dimColor>{reset ? `resets in ${reset}` : 'reset time not reported'}</Text>
115        </Box>
116      )
117    }
118
119    const age = r.at ? Math.max(0, Math.round((now - r.at) / 60000)) : null
120
121    return (
122      <Box flexDirection="column" paddingX={1}>
123        {r.limits.length === 0 ? (
124          <Text dimColor wrap="wrap">
125            No limit reading yet. It appears after Claude's next reply, on a Claude subscription.
126          </Text>
127        ) : (
128          <Box flexDirection="column">{r.limits.map(row)}</Box>
129        )}
130        <Box flexDirection="column" marginTop={1}>
131          <Text bold>This chat</Text>
132          <Text dimColor>
133            {`cost ${r.costUsd === null ? '—' : `$${r.costUsd.toFixed(2)}`} · context ${r.contextPercent === null ? '—' : pct(r.contextPercent)}`}
134          </Text>
135        </Box>
136        <Box marginTop={1}>
137          <Text dimColor wrap="wrap">
138            {age === null
139              ? 'Limits count claude.ai and Claude Code together.'
140              : `Read ${age === 0 ? 'just now' : `${age}m ago`} from Claude's last reply. Limits count claude.ai and Claude Code together.`}
141          </Text>
142        </Box>
143      </Box>
144    )
145  })
146}
147
hooks/meter.ts 77 lines
1/** Pure helpers: limit names, countdowns, bars, colours and the warning thresholds. */
2
3import type { Limit } from '../types'
4
5export const THRESHOLDS = [75, 90] as const
6
7/** "five_hour" -> "Session (5h)", "seven_day" -> "Week", "seven_day_opus" -> "Week (Opus)". */
8export const labelOf = (kind: string) => {
9  if (kind === 'five_hour') return 'Session (5h)'
10  if (kind === 'seven_day') return 'Week'
11  if (kind === 'spend_limit') return 'Spend limit'
12  const week = /^seven_day_(.+)$/.exec(kind)
13  if (week) return `Week (${week[1]!.charAt(0).toUpperCase()}${week[1]!.slice(1).replace(/_/g, ' ')})`
14  return kind.replace(/_/g, ' ')
15}
16
17/** Short label for the status line. */
18export const shortOf = (kind: string) => (kind === 'five_hour' ? 'Session' : kind === 'seven_day' ? 'Week' : labelOf(kind))
19
20/** Milliseconds until a reset -> "2h 10m", "3d 4h", "45m", "now". */
21export const countdown = (ms: number) => {
22  if (ms <= 0) return 'now'
23  const m = Math.ceil(ms / 60000)
24  const d = Math.floor(m / 1440)
25  const h = Math.floor((m % 1440) / 60)
26  const min = m % 60
27  if (d > 0) return `${d}d ${h}h`
28  if (h > 0) return `${h}h ${String(min).padStart(2, '0')}m`
29  return `${min}m`
30}
31
32export const resetIn = (limit: Limit, now: number) => {
33  if (!limit.resetsAt) return null
34  const at = Date.parse(limit.resetsAt)
35  return Number.isNaN(at) ? null : countdown(at - now)
36}
37
38/** "████████░░░░" at a width. */
39export const bar = (percent: number, width: number) => {
40  const full = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
41  return '█'.repeat(full) + '░'.repeat(width - full)
42}
43
44export type Level = 'ok' | 'warn' | 'high'
45export const levelOf = (percent: number): Level => (percent >= 90 ? 'high' : percent >= 75 ? 'warn' : 'ok')
46
47export const pct = (n: number) => `${Math.round(n)}%`
48
49/** The status line: "Session 42% · resets 2h 10m · Week 18%". */
50export const statusLine = (limits: readonly Limit[], now: number) => {
51  if (limits.length === 0) return undefined
52  const order = (k: string) => (k === 'five_hour' ? 0 : k === 'seven_day' ? 1 : 2)
53  const sorted = [...limits].sort((a, b) => order(a.kind) - order(b.kind)).slice(0, 2)
54  return sorted
55    .map((l, i) => {
56      const reset = i === 0 ? resetIn(l, now) : null
57      return `${shortOf(l.kind)} ${pct(l.percentUsed)}${reset ? ` · resets ${reset}` : ''}`
58    })
59    .join(' · ')
60}
61
62/**
63 * The warnings a reading crosses that were not shown yet: one per limit,
64 * the highest threshold crossed, keyed by limit, reset time and threshold.
65 */
66export const dueWarnings = (limits: readonly Limit[], shown: readonly string[]) => {
67  const due: { key: string; text: string }[] = []
68  for (const l of limits) {
69    const crossed = [...THRESHOLDS].reverse().find(t => l.percentUsed >= t)
70    if (!crossed) continue
71    const key = `${l.kind}@${l.resetsAt ?? ''}@${crossed}`
72    if (shown.includes(key)) continue
73    due.push({ key, text: `${labelOf(l.kind)} limit is at ${pct(l.percentUsed)}${crossed >= 90 ? ': nearly out' : ''}` })
74  }
75  return due
76}
77
types/index.d.ts 19 lines
1export type Limit = {
2  kind: string
3  percentUsed: number
4  resetsAt?: string
5}
6
7export type Reading = {
8  limits: Limit[]
9  costUsd: number | null
10  contextPercent: number | null
11  at: number | null
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'usage-tally': { reading: Reading; now: number }
17  }
18}
19