Your plan's 5-hour and weekly usage above the prompt, with the time left until each resets.

A Claude Code mod that keeps your plan's usage in view: the 5-hour session window and the weekly window, each with a bar, the percentage used and the time left until it resets.
Claude │ session █▌··· 31% ↻3h 16m │ weekly ▍···· 7% ↻5d 18h
↻3h 16m is the time until that window resets.$.session.usage()), refresh after every reply and cost no tokens. Nothing leaves your machine.Requires Claude Code 2.1.287 or later.
/plugin marketplace add santtisosa/plan-limits
/plugin install plan-limits@plan-limits
/config)| Setting | Default | |
|---|---|---|
| Placement | hint | hint: at the end of the hint line under the prompt. band: a row above the prompt. status: a pinned notice under the prompt. |
| Bar width | 5 | Cells per bar. |
| Normal / Warning / Critical color | green / yellow / red | Colors of the bar and percentage. |
Accounts without plan limits (API keys) have no windows to show; with status placement the line reads "plan limits: none reported yet".
claude plugin validate .
claude plugin test .
hooks/limits.ts holds the pure logic (pace, levels, bar, durations); hooks/register.tsx wires it to Claude Code.
MIT
hooks/register.tsx 113 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit } from '../types'
5import { describeLimit, DIVIDER, formatBar, formatLine, RESET_MARK, TITLE } from './limits'
6import type { Reading } from './limits'
7
8const REFRESH_MS = 60_000
9const NO_LIMITS = 'plan limits: none reported yet'
10
11const limits = atom({ plugin: 'plan-limits', key: 'limits' } as const, [] as Limit[])
12const now = atom({ plugin: 'plan-limits', key: 'now' } as const, 0)
13
14const readingsOf = (all: readonly Limit[], at: number): Reading[] =>
15 all.map(limit => describeLimit(limit, at)).filter(reading => reading !== undefined)
16
17type Display = { placement: string; isStatus: boolean; barWidth: number }
18
19async function refresh($: EngineInterface, display: Display, latest?: readonly Limit[]) {
20 if(latest !== undefined) {
21 await update($, limits, () => [...latest])
22 }
23
24 await update($, now, () => Date.now())
25
26 if(display.isStatus) {
27 const readings = readingsOf(await read($, limits), await read($, now))
28 $.ui.status(readings.length === 0 ? NO_LIMITS : formatLine(readings, display.barWidth))
29 }
30}
31
32type Elements = ReturnType<EngineInterface['ui']['resolve']>
33type Colors = Record<Reading['level'], string>
34
35const limitsTree = (readings: readonly Reading[], barWidth: number, colors: Colors, { Box, Text }: Elements) => (
36 <Box gap={1}>
37 <Text dimColor>{TITLE}</Text>
38 {readings.map(reading => {
39 const bar = formatBar(reading.percent, barWidth)
40
41 return (
42 <Box key={reading.label} gap={1}>
43 <Text dimColor>{DIVIDER}</Text>
44 <Text>{reading.label}</Text>
45 <Box>
46 <Text color={colors[reading.level]}>{bar.filled}</Text>
47 <Text dimColor>{bar.empty}</Text>
48 </Box>
49 <Text color={colors[reading.level]} bold>{`${reading.percent}%`}</Text>
50 {reading.resetsIn === undefined ? null : <Text dimColor>{`${RESET_MARK}${reading.resetsIn}`}</Text>}
51 {reading.isOnPaceToRunOut ? <Text color={colors.hot}>on pace to run out</Text> : null}
52 </Box>
53 )
54 })}
55 </Box>
56)
57
58export const register: Register = (on, options) => {
59 const placement = String(options.placement ?? 'hint')
60 const isStatus = placement === 'status'
61 const barWidth = Number(options.barWidth ?? 5)
62 const display = { placement, isStatus, barWidth }
63 const colors = {
64 ok: String(options.okColor ?? 'green'),
65 warn: String(options.warnColor ?? 'yellow'),
66 hot: String(options.hotColor ?? 'red'),
67 }
68
69 on('session.start', async ($, e, next) => {
70 const result = await next(e)
71
72 if(!isStatus) {
73 $.ui.status(undefined)
74 }
75
76 await refresh($, display, (await $.session.usage()).rateLimits)
77 $.clock.every(REFRESH_MS, () => void refresh($, display))
78
79 return result
80 })
81
82 on('session.measure', async ($, e, next) => {
83 if(e.changed.includes('rateLimits')) {
84 await refresh($, display, e.rateLimits)
85 }
86
87 return next(e)
88 })
89
90 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
91 const line = await next(e)
92 const readings = readingsOf(await read($, limits), await read($, now))
93
94 if(placement !== 'hint' || readings.length === 0) {
95 return line
96 }
97
98 const elements = $.ui.resolve(e)
99
100 return <elements.Box gap={2}>{line}{limitsTree(readings, barWidth, colors, elements)}</elements.Box>
101 })
102
103 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104 const readings = readingsOf(await read($, limits), await read($, now))
105
106 if(placement !== 'band' || e.props.hasSurvey || readings.length === 0) {
107 return next(e)
108 }
109
110 return limitsTree(readings, barWidth, colors, $.ui.resolve(e))
111 })
112}
113hooks/limits.ts 97 lines1import type { Limit } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6const MIN_ELAPSED_FOR_PACE = 0.05
7const HOT_PERCENT = 90
8const WARN_PERCENT = 70
9const HOT_PACE = 100
10const WARN_PACE = 85
11
12const WINDOWS: Record<string, { label: string; ms: number }> = {
13 five_hour: { label: 'session', ms: 5 * HOUR },
14 seven_day: { label: 'weekly', ms: 7 * DAY },
15}
16
17export type Level = 'ok' | 'warn' | 'hot'
18
19export type Reading = {
20 label: string
21 percent: number
22 resetsIn?: string
23 level: Level
24 isOnPaceToRunOut: boolean
25}
26
27const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
28
29export const formatBar = (percent: number, width: number): { filled: string; empty: string } => {
30 const cells = Math.max(1, Math.round(width))
31 const eighths = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells * 8)
32 const full = Math.floor(eighths / 8)
33 const partial = EIGHTHS[eighths % 8] ?? ''
34 const used = full + (partial === '' ? 0 : 1)
35
36 return { filled: '█'.repeat(full) + partial, empty: '·'.repeat(cells - used) }
37}
38
39export const formatDuration = (ms: number): string => {
40 const minutes = Math.max(0, Math.round(ms / MINUTE))
41 const hours = Math.floor(minutes / 60)
42 const days = Math.floor(hours / 24)
43
44 if(days > 0) {
45 return `${days}d ${hours % 24}h`
46 }
47
48 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
49}
50
51const projectedPercent = (percent: number, msLeft: number, windowMs: number): number => {
52 const elapsed = 1 - msLeft / windowMs
53
54 return elapsed > MIN_ELAPSED_FOR_PACE ? percent / elapsed : 0
55}
56
57const levelOf = (percent: number, projected: number): Level => {
58 if(percent >= HOT_PERCENT || projected >= HOT_PACE) {
59 return 'hot'
60 }
61
62 return percent >= WARN_PERCENT || projected >= WARN_PACE ? 'warn' : 'ok'
63}
64
65export const describeLimit = (limit: Limit, now: number): Reading | undefined => {
66 const window = WINDOWS[limit.kind]
67
68 if(window === undefined) {
69 return undefined
70 }
71
72 const msLeft = limit.resetsAt === undefined ? undefined : Date.parse(limit.resetsAt) - now
73 const hasReset = msLeft !== undefined && msLeft > 0
74 const projected = hasReset ? projectedPercent(limit.percentUsed, msLeft, window.ms) : 0
75
76 return {
77 label: window.label,
78 percent: Math.round(limit.percentUsed),
79 resetsIn: hasReset ? formatDuration(msLeft) : undefined,
80 level: levelOf(limit.percentUsed, projected),
81 isOnPaceToRunOut: projected >= HOT_PACE,
82 }
83}
84
85export const TITLE = 'Claude'
86export const DIVIDER = '│'
87export const RESET_MARK = '↻'
88
89export const formatLine = (readings: readonly Reading[], barWidth: number): string =>
90 [TITLE, ...readings.map(reading => {
91 const bar = formatBar(reading.percent, barWidth)
92 const resets = reading.resetsIn === undefined ? '' : ` ${RESET_MARK}${reading.resetsIn}`
93 const pace = reading.isOnPaceToRunOut ? ' · on pace to run out' : ''
94
95 return `${reading.label} ${bar.filled}${bar.empty} ${reading.percent}%${resets}${pace}`
96 })].join(` ${DIVIDER} `)
97types/index.d.ts 8 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'plan-limits': { limits: Limit[]; now: number }
6 }
7}
8