Context window, session and weekly plan usage as progress bars at the bottom right of the prompt footer

See how much of your context window and plan limits you have used without leaving the Claude Code terminal. lenadweb-usage-meter draws three progress bars at the bottom right of the prompt footer, the same figures the Claude desktop app shows in its usage popover.

Bars are blue, turn amber past 70% and red past 90%. On a narrow terminal the footer drops the token counts and reset times first, then switches to short labels (ctx, 5h, 7d), and finally shows percentages only, so it always fits on one line.
Session and weekly limits appear after the first reply of a session, because Claude Code learns them from API responses, and only on a Pro or Max subscription. The context bar is there from the start.
Nothing. The plugin makes no network requests, runs no programs, reads no files and sends no data anywhere. It only draws the usage figures Claude Code already has for the session.
| Event | What the hook does |
|---|---|
session.start | Reads the session's usage figures and starts a one-minute clock for the reset countdowns. |
session.measure | Stores the new figures Claude Code measures after each turn. |
ui.render for the footer mode labels | Draws the progress bars at the right of the prompt footer, keeping any mode labels Claude Code shows there. |
No hook changes, blocks or rewrites a prompt, a tool call or anything Claude sees.
MIT
hooks/register.tsx 53 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { renderFooter } from './footer'
5import { pickLayout } from './layout'
6import { buildSegments, toUsage } from './usage'
7
8const usageAtom = atom({ plugin: 'lenadweb-usage-meter', key: 'usage' } as const, null)
9const nowAtom = atom({ plugin: 'lenadweb-usage-meter', key: 'now' } as const, 0)
10
11const CLOCK_TICK_MS = 60_000
12
13async function refreshClock($: EngineInterface): Promise<void> {
14 const now = await $.clock.now()
15 await update($, nowAtom, () => now)
16}
17
18export const register: Register = on => {
19 on('session.start', async ($, e, next) => {
20 const result = await next(e)
21
22 const { context, rateLimits } = await $.session.usage()
23 await update($, usageAtom, () => toUsage(context, rateLimits))
24 await refreshClock($)
25
26 $.clock.every(CLOCK_TICK_MS, () => {
27 void refreshClock($)
28 })
29
30 return result
31 })
32
33 on('session.measure', async ($, e, next) => {
34 await update($, usageAtom, () => toUsage(e.context, e.rateLimits))
35 await refreshClock($)
36 return next(e)
37 })
38
39 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
40 const usage = await read($, usageAtom)
41 if (usage === null) {
42 return next(e)
43 }
44
45 const now = await read($, nowAtom)
46 const modes = e.props.modes.join(' & ')
47 const segments = buildSegments(usage, now)
48 const layout = pickLayout(segments, modes, e.viewport?.columns)
49
50 return renderFooter($.ui.resolve(e), modes, segments, layout)
51 })
52}
53hooks/footer.tsx 72 lines1import type { Elements } from 'claude-code'
2
3import { formatPercent } from './format'
4import { labelOf, PERCENT_WIDTH } from './layout'
5import type { Layout } from './layout'
6import type { Segment } from './usage'
7
8type FooterElements = Pick<Elements['terminal'], 'Box' | 'Text'>
9
10const BLOCK = '█'
11const WARNING_PERCENT = 70
12const DANGER_PERCENT = 90
13
14export function renderFooter(ui: FooterElements, modes: string, segments: Segment[], layout: Layout) {
15 const { Box, Text } = ui
16 return (
17 <Box flexDirection="row" gap={layout.gap}>
18 {modes ? <Text dimColor>{modes}</Text> : null}
19 {segments.map(segment => renderMeter(ui, segment, layout))}
20 </Box>
21 )
22}
23
24function renderMeter(ui: FooterElements, segment: Segment, layout: Layout) {
25 const { Box, Text } = ui
26 const isHigh = segment.percent >= WARNING_PERCENT
27 const showDetail = layout.showDetails && segment.detail
28
29 return (
30 <Box key={segment.key} flexDirection="row" gap={1}>
31 <Text dimColor>{labelOf(segment, layout)}</Text>
32 {layout.barWidth > 0 ? renderBar(ui, segment.percent, layout.barWidth) : null}
33 <Text bold={isHigh} color={isHigh ? barColor(segment.percent) : undefined}>
34 {formatPercent(segment.percent).padStart(PERCENT_WIDTH)}
35 </Text>
36 {showDetail ? <Text dimColor>{segment.detail}</Text> : null}
37 </Box>
38 )
39}
40
41function renderBar(ui: FooterElements, percent: number, width: number) {
42 const { Box, Text } = ui
43 const filled = filledCells(percent, width)
44 return (
45 <Box flexDirection="row">
46 <Text color={barColor(percent)}>{BLOCK.repeat(filled)}</Text>
47 <Text dimColor>{BLOCK.repeat(width - filled)}</Text>
48 </Box>
49 )
50}
51
52function filledCells(percent: number, width: number): number {
53 if (percent <= 0) {
54 return 0
55 }
56 if (percent >= 100) {
57 return width
58 }
59 const cells = Math.round((percent / 100) * width)
60 return Math.min(width - 1, Math.max(1, cells))
61}
62
63function barColor(percent: number): string {
64 if (percent >= DANGER_PERCENT) {
65 return 'error'
66 }
67 if (percent >= WARNING_PERCENT) {
68 return 'warning'
69 }
70 return 'suggestion'
71}
72hooks/layout.ts 50 lines1import type { Segment } from './usage'
2
3export type Layout = {
4 useShortLabels: boolean
5 barWidth: number
6 showDetails: boolean
7 gap: number
8}
9
10export const PERCENT_WIDTH = 4
11
12const FOOTER_LEFT_SIDE_WIDTH = 62
13
14const FULL: Layout = { useShortLabels: false, barWidth: 12, showDetails: true, gap: 3 }
15const WITHOUT_DETAILS: Layout = { ...FULL, showDetails: false }
16const SHORT: Layout = { useShortLabels: true, barWidth: 8, showDetails: false, gap: 2 }
17const PERCENT_ONLY: Layout = { ...SHORT, barWidth: 0 }
18
19const LAYOUTS_FROM_WIDEST = [FULL, WITHOUT_DETAILS, SHORT, PERCENT_ONLY]
20
21export function pickLayout(segments: Segment[], modes: string, terminalColumns: number | undefined): Layout {
22 if (terminalColumns === undefined) {
23 return FULL
24 }
25
26 const freeColumns = terminalColumns - FOOTER_LEFT_SIDE_WIDTH
27 const fitting = LAYOUTS_FROM_WIDEST.find(layout => footerWidth(segments, modes, layout) <= freeColumns)
28 return fitting ?? PERCENT_ONLY
29}
30
31export function labelOf(segment: Segment, layout: Layout): string {
32 return layout.useShortLabels ? segment.shortLabel : segment.label
33}
34
35function footerWidth(segments: Segment[], modes: string, layout: Layout): number {
36 const widths = segments.map(segment => meterWidth(segment, layout))
37 if (modes) {
38 widths.push(modes.length)
39 }
40 const gaps = layout.gap * (widths.length - 1)
41 return widths.reduce((total, width) => total + width, gaps)
42}
43
44function meterWidth(segment: Segment, layout: Layout): number {
45 const label = labelOf(segment, layout).length + 1
46 const bar = layout.barWidth > 0 ? layout.barWidth + 1 : 0
47 const detail = layout.showDetails && segment.detail ? segment.detail.length + 1 : 0
48 return label + bar + PERCENT_WIDTH + detail
49}
50hooks/usage.ts 73 lines1import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3import type { Limit, Usage } from '../types'
4import { formatTimeLeft, formatTokens } from './format'
5
6export type Segment = {
7 key: string
8 label: string
9 shortLabel: string
10 percent: number
11 detail?: string
12}
13
14const LIMIT_NAMES: Record<string, { label: string; shortLabel: string }> = {
15 five_hour: { label: 'Session', shortLabel: '5h' },
16 seven_day: { label: 'Weekly', shortLabel: '7d' },
17 spend_limit: { label: 'Spend', shortLabel: '$' },
18}
19
20const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
21
22export function toUsage(context: SessionContextUsage, rateLimits: readonly SessionRateLimit[]): Usage {
23 return {
24 contextPercent: context.percent,
25 contextTokens: context.tokens,
26 contextWindow: context.window,
27 limits: rateLimits.map(limit => ({
28 kind: limit.kind,
29 percentUsed: limit.percentUsed,
30 resetsAt: limit.resetsAt,
31 })),
32 }
33}
34
35export function buildSegments(usage: Usage, now: number): Segment[] {
36 const contextSegment = toContextSegment(usage)
37 const limitSegments = sortLimits(usage.limits).map(limit => toLimitSegment(limit, now))
38 return [contextSegment, ...limitSegments]
39}
40
41function toContextSegment(usage: Usage): Segment {
42 const used = formatTokens(usage.contextTokens ?? 0)
43 const total = formatTokens(usage.contextWindow)
44 return {
45 key: 'context',
46 label: 'Context',
47 shortLabel: 'ctx',
48 percent: usage.contextPercent ?? 0,
49 detail: `${used}/${total}`,
50 }
51}
52
53function toLimitSegment(limit: Limit, now: number): Segment {
54 const names = LIMIT_NAMES[limit.kind] ?? { label: limit.kind, shortLabel: limit.kind }
55 const timeLeft = formatTimeLeft(limit.resetsAt, now)
56 return {
57 key: limit.kind,
58 label: names.label,
59 shortLabel: names.shortLabel,
60 percent: limit.percentUsed,
61 detail: timeLeft ? `↻ ${timeLeft}` : undefined,
62 }
63}
64
65function sortLimits(limits: readonly Limit[]): Limit[] {
66 return [...limits].sort((a, b) => orderOf(a.kind) - orderOf(b.kind))
67}
68
69function orderOf(kind: string): number {
70 const index = LIMIT_ORDER.indexOf(kind)
71 return index === -1 ? LIMIT_ORDER.length : index
72}
73hooks/format.ts 42 lines1const MINUTE_MS = 60_000
2const MINUTES_PER_HOUR = 60
3const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
4
5export function formatTokens(tokens: number): string {
6 if (tokens >= 1_000_000) {
7 const millions = tokens / 1_000_000
8 return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`
9 }
10 if (tokens >= 1_000) {
11 return `${Math.round(tokens / 1_000)}k`
12 }
13 return String(tokens)
14}
15
16export function formatPercent(percent: number): string {
17 return `${Math.round(percent)}%`
18}
19
20export function formatTimeLeft(resetsAt: string | undefined, now: number): string | undefined {
21 if (!resetsAt || !now) {
22 return undefined
23 }
24
25 const minutesLeft = Math.floor((Date.parse(resetsAt) - now) / MINUTE_MS)
26 if (!(minutesLeft > 0)) {
27 return undefined
28 }
29
30 const days = Math.floor(minutesLeft / MINUTES_PER_DAY)
31 const hours = Math.floor((minutesLeft % MINUTES_PER_DAY) / MINUTES_PER_HOUR)
32 const minutes = minutesLeft % MINUTES_PER_HOUR
33
34 if (days > 0) {
35 return `${days}d ${hours}h`
36 }
37 if (hours > 0) {
38 return `${hours}h ${minutes}m`
39 }
40 return `${minutes}m`
41}
42types/index.d.ts 15 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = {
4 contextPercent?: number
5 contextTokens?: number
6 contextWindow: number
7 limits: Limit[]
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'lenadweb-usage-meter': { usage: Usage | null; now: number }
13 }
14}
15