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…

Your Claude plan usage inside Claude Code, like the Tally browser extension shows on claude.ai.
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 fillThe 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.
| Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|
| No | No | No | No | No |
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.
hooks/register.tsx 147 lines1import { 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}
147hooks/meter.ts 77 lines1/** 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}
77types/index.d.ts 19 lines1export 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