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

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%
claude auth status (Claude Pro, Claude Max, ...), Claude if unknown.session refreshes after every model request, week and the plan after every turn.- and empty bars until data is available.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:
Claude Code-credentials) inside a child python3 process, so it never appears in a command line, and is sent only to api.anthropic.com.- until the first response.claude on PATH (plan lookup). macOS and python3 for the startup fetch.
hooks/register.tsx 153 lines1import 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