Shows 5-hour and weekly usage limits as bars above the prompt, with reset times and a weekly pace forecast

hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Limits } from '../types'
5import { FIVE_HOURS, WEEK, bar, colorFor, duration, forecast } from './forecast'
6
7const limits = atom({ plugin: 'usage-bar', key: 'limits' } as const, {
8 fiveHour: null,
9 weekly: null,
10})
11const isVisible = atom({ plugin: 'usage-bar', key: 'isVisible' } as const, true)
12const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
13
14const pick = (rateLimits: SessionRateLimit[], kind: string): Limit | null => {
15 const found = rateLimits.find(one => one.kind === kind)
16
17 return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt } : null
18}
19
20const toLimits = (rateLimits: SessionRateLimit[]): Limits => ({
21 fiveHour: pick(rateLimits, 'five_hour'),
22 weekly: pick(rateLimits, 'seven_day'),
23})
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await $.command.register({
28 name: 'usage-bar',
29 description: 'Toggle the usage limit bars above the prompt',
30 immediate: true,
31 })
32
33 const usage = await $.session.usage()
34 const t = await $.clock.now()
35 await update($, limits, () => toLimits(usage.rateLimits))
36 await update($, now, () => t)
37
38 // Keeps the "resets in" countdowns fresh between turns.
39 $.clock.every(60_000, () => {
40 void $.clock.now().then(t => update($, now, () => t))
41 })
42
43 return next(e)
44 })
45
46 on('session.measure', async ($, e, next) => {
47 if (e.changed.includes('rateLimits')) {
48 const t = await $.clock.now()
49 await update($, limits, () => toLimits(e.rateLimits))
50 await update($, now, () => t)
51 }
52
53 return next(e)
54 })
55
56 on('command.run', { command: 'usage-bar' }, async $ => {
57 const shown = await update($, isVisible, v => !v)
58
59 return { text: shown ? 'Usage bars shown.' : 'Usage bars hidden.' }
60 })
61
62 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63 const { fiveHour, weekly } = await read($, limits)
64
65 if (e.props.hasSurvey || !(await read($, isVisible)) || (!fiveHour && !weekly)) {
66 return next(e)
67 }
68
69 const { Box, Text } = $.ui.resolve(e)
70 const t = (await read($, now)) || (await $.clock.now())
71 const width = Math.max(10, Math.min(30, e.props.bodyColumns - 50))
72
73 const row = (label: string, limit: Limit | null, windowMs: number, withForecast: boolean) => {
74 if (!limit) {
75 return (
76 <Text key={label} dimColor>
77 {label} no reading yet
78 </Text>
79 )
80 }
81
82 const resets = limit.resetsAt
83 ? `resets in ${duration(Date.parse(limit.resetsAt) - t)}`
84 : ''
85 const pace = withForecast ? forecast(limit, windowMs, t) : { kind: 'unknown' as const }
86
87 return (
88 <Box key={label} flexDirection="row">
89 <Text>{label} </Text>
90 <Text color={colorFor(limit.percentUsed)}>{bar(limit.percentUsed, width)}</Text>
91 <Text> {limit.percentUsed.toFixed(0).padStart(3)}% </Text>
92 <Text dimColor>{resets}</Text>
93 {pace.kind === 'ok' && <Text dimColor> · on pace for ~{pace.atReset.toFixed(0)}%</Text>}
94 {pace.kind === 'exceeds' && (
95 <Text color="red" bold>
96 {' '}
97 ⚠ on pace to hit limit in {duration(pace.hitsAt - t)}
98 </Text>
99 )}
100 </Box>
101 )
102 }
103
104 return (
105 <Box flexDirection="column">
106 {row('5h ', fiveHour, FIVE_HOURS, false)}
107 {row('week', weekly, WEEK, true)}
108 </Box>
109 )
110 })
111}
112hooks/forecast.ts 60 lines1import type { Limit } from '../types'
2
3export const FIVE_HOURS = 5 * 60 * 60 * 1000
4export const WEEK = 7 * 24 * 60 * 60 * 1000
5
6export type Forecast =
7 | { kind: 'unknown' }
8 | { kind: 'ok'; atReset: number }
9 | { kind: 'exceeds'; hitsAt: number }
10
11// Linear pace over the elapsed part of the window: the rate so far, carried to reset.
12export const forecast = (limit: Limit, windowMs: number, now: number): Forecast => {
13 if (!limit.resetsAt) {
14 return { kind: 'unknown' }
15 }
16
17 const resetsAt = Date.parse(limit.resetsAt)
18 const elapsed = windowMs - (resetsAt - now)
19
20 // Too early in the window for the pace to mean anything.
21 if (elapsed < windowMs * 0.02 || limit.percentUsed <= 0) {
22 return { kind: 'unknown' }
23 }
24
25 const perMs = limit.percentUsed / elapsed
26 const atReset = limit.percentUsed + perMs * (resetsAt - now)
27
28 if (limit.percentUsed >= 100 || atReset > 100) {
29 return { kind: 'exceeds', hitsAt: now + Math.max(0, (100 - limit.percentUsed) / perMs) }
30 }
31
32 return { kind: 'ok', atReset }
33}
34
35export const duration = (ms: number): string => {
36 const minutes = Math.max(0, Math.round(ms / 60000))
37 const days = Math.floor(minutes / 1440)
38 const hours = Math.floor((minutes % 1440) / 60)
39 const mins = minutes % 60
40
41 if (days > 0) {
42 return `${days}d ${hours}h`
43 }
44
45 if (hours > 0) {
46 return `${hours}h ${mins}m`
47 }
48
49 return `${mins}m`
50}
51
52export const bar = (percent: number, width: number): string => {
53 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
54
55 return '█'.repeat(filled) + '░'.repeat(width - filled)
56}
57
58export const colorFor = (percent: number): string =>
59 percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green'
60types/index.d.ts 10 lines1export type Limit = { percentUsed: number; resetsAt?: string }
2
3export type Limits = { fiveHour: Limit | null; weekly: Limit | null }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'usage-bar': { limits: Limits; isVisible: boolean; now: number }
8 }
9}
10