5-hour and weekly plan usage meters with pace markers, reset countdowns and session cost, above the prompt

Three Claude Code mods for keeping an eye on how much room you have left and what you spend: your context window, your plan usage limits, and a receipt for each turn. They work in the terminal and in the Claude desktop app's Code tab.
| Mod | Where it shows | What it shows |
|---|---|---|
context-window | A pane beside the conversation | A colour-coded bar of what fills the context window, with a legend of every category (tokens and share), the autocompact buffer, and a warning at 50% |
plan-usage | A band above the prompt | 5-hour and weekly limit meters with a pace marker, time until reset, an ahead/under-pace label, and the session's cost |
turn-receipt | A card under each turn's answer | What the turn cost, its tokens by category, duration, model, the tools that ran, and how much tool output went back to the model |
context-window and plan-usage also add a short entry to the status line.
In a Claude Code terminal session:
/plugin install context-window --marketplace oakker-myo/claude-code-mods
/plugin install plan-usage --marketplace oakker-myo/claude-code-mods
/plugin install turn-receipt --marketplace oakker-myo/claude-code-mods
Answer y to add the marketplace, then pick a scope (the user scope loads them in every session). Installed at the user scope from a terminal, they also load in the desktop app's Code tab.
Or from a shell:
claude plugin marketplace add oakker-myo/claude-code-mods
claude plugin install context-window@claude-code-mods --scope user
claude plugin install plan-usage@claude-code-mods --scope user
claude plugin install turn-receipt@claude-code-mods --scope user
| Command | Effect |
|---|---|
/context-window | Open or close the context-window pane |
/plan-usage | Show or hide the plan-usage band |
/turn-receipt | Show or hide the receipts under each turn |
/compact.Context window · 11%.│: how much of the window's time has passed. Usage left of it is under pace.Plan meters appear on Pro and Max plans after the first reply of a session.
A small card under the answer that closes each turn:
Tools calls output fed back
Read 2 ██████████ ≈261
Bash 3 ███████ ≈182
+ 2 more (ExitPlanMode, ToolSearch) · 2 calls ≈457
─────────────────────────────────────────────
7 calls · ≈900 tokens fed back
The four tools with the most output get a row; the rest, and Claude's own bookkeeping tools (ToolSearch, plan mode, todo lists), fold into one "+ more" line. A turn with fewer than three calls and under 1k tokens of output shows one line instead, e.g. Tools Bash ×2 · ≈180 fed back.
A turn that ends on a tool call with no closing text has no answer to hang a receipt under, so it shows none.
Claude Code with plugin hooks modules (2.1.293 or later).
Each mod is a plugin folder: .claude-plugin/plugin.json, a hooks module in hooks/, and its state contract in types/. The repository root holds the marketplace file, .claude-plugin/marketplace.json.
claude plugin validate .
claude plugin validate context-window
claude plugin test context-window
claude plugin test plan-usage
claude plugin test turn-receipt
To try a change without installing, start a session with claude --plugin-dir ./context-window --plugin-dir ./plan-usage --plugin-dir ./turn-receipt. If you installed from a clone of this folder (claude plugin marketplace add <folder>), Claude Code installs from the clone's last commit, so a change reaches your sessions once it is committed:
git commit -am "…"
claude plugin update <mod>@claude-code-mods
then run /reload-plugins in a running session.
See CHANGELOG.md for release notes.
hooks/register.tsx 165 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Usage } from '../types'
5import { TONE_HEX, crossed, elapsed, label, meter, pace, runs, short, tone, until } from './format'
6
7const usage = atom({ plugin: 'plan-usage', key: 'usage' } as const, null)
8const isHidden = atom({ plugin: 'plan-usage', key: 'isHidden' } as const, false)
9
10const MARKS = [80, 95]
11
12type Measured = {
13 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
14 cost?: { usd: number }
15}
16
17const toUsage = (m: Measured): Usage => ({
18 limits: m.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
19 usd: m.cost?.usd,
20})
21
22const statusText = (u: Usage): string | undefined => {
23 const parts = u.limits.map(l => `${short(l.kind)} ${l.percentUsed}%`)
24 if (u.usd !== undefined) parts.push(`$${u.usd.toFixed(2)}`)
25 return parts.length ? parts.join(' · ') : undefined
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 const ran = await next(e)
31 await $.command.register({
32 name: 'plan-usage',
33 description: 'Show or hide the plan-usage band above the prompt',
34 })
35 const fresh = toUsage(await $.session.usage())
36 await update($, usage, () => fresh)
37 $.ui.status(statusText(fresh))
38 // Keeps the reset countdowns and pace ticks current between turns.
39 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
40
41 return ran
42 })
43
44 on('session.measure', async ($, e, next) => {
45 const before = await read($, usage)
46 const fresh = toUsage(e)
47 await update($, usage, () => fresh)
48 $.ui.status(statusText(fresh))
49
50 for (const l of fresh.limits) {
51 const was = before?.limits.find(b => b.kind === l.kind)?.percentUsed
52 if (crossed(was, l.percentUsed, MARKS) !== undefined) {
53 $.ui.toast(`${label(l.kind)} limit at ${l.percentUsed}%`)
54 }
55 }
56
57 return next(e)
58 })
59
60 on('command.run', { command: 'plan-usage' }, async $ => {
61 const hidden = await update($, isHidden, h => !h)
62
63 return { text: hidden ? 'Plan-usage band hidden.' : 'Plan-usage band shown.' }
64 })
65
66 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
67 const below = await next(e)
68 const u = await read($, usage)
69 if (e.props.hasSurvey || u === null || (await read($, isHidden))) {
70 return below
71 }
72
73 const { Box, Text } = $.ui.resolve(e)
74 const now = await $.clock.now()
75 const cells = Math.max(10, Math.min(40, e.props.bodyColumns - 48))
76
77 const rowsOut = u.limits.map(l => {
78 const at = elapsed(l, now)
79 const how = pace(l.percentUsed, at)
80 const left = until(l.resetsAt, now)
81 const color = tone(l.percentUsed)
82 const bar = (
83 <Text>
84 {runs(meter(l.percentUsed, cells, at)).map(r => (
85 <Text
86 color={r.kind === 'fill' ? TONE_HEX[color] : r.kind === 'pace' ? 'text' : undefined}
87 dimColor={r.kind === 'empty'}
88 bold={r.kind === 'pace'}
89 >
90 {r.text}
91 </Text>
92 ))}
93 </Text>
94 )
95
96 return (
97 <Box key={l.kind} flexDirection="row" alignItems="center" columnGap={2}>
98 <Box width={8}>
99 <Text bold>{label(l.kind)}</Text>
100 </Box>
101 {bar}
102 <Box width={6} justifyContent="flex-end">
103 <Text bold color={color}>
104 {l.percentUsed}%
105 </Text>
106 </Box>
107 <Box width={12}>
108 <Text dimColor>{left ? `↻ ${left}` : ''}</Text>
109 </Box>
110 {how ? (
111 <Text
112 color={how === 'ahead' ? 'warning' : how === 'behind' ? 'success' : 'inactive'}
113 italic
114 >
115 {how === 'ahead' ? '▲ ahead of pace' : how === 'behind' ? '▼ under pace' : '● on pace'}
116 </Text>
117 ) : null}
118 </Box>
119 )
120 })
121
122 const card = (
123 <Box
124 key="plan-usage"
125 flexDirection="column"
126 borderStyle="round"
127 borderColor="suggestion"
128 paddingX={1}
129 marginBottom={1}
130 >
131 <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
132 <Text bold color="suggestion">
133 ◷ Plan usage
134 </Text>
135 {u.usd !== undefined ? (
136 <Text>
137 <Text dimColor>this session </Text>
138 <Text bold>${u.usd.toFixed(2)}</Text>
139 </Text>
140 ) : null}
141 </Box>
142 {rowsOut.length ? (
143 <Box flexDirection="column">{rowsOut}</Box>
144 ) : (
145 <Text dimColor>No reading yet: appears on a Pro or Max plan after the first reply.</Text>
146 )}
147 {rowsOut.length ? (
148 <Box marginTop={1}>
149 <Text dimColor>│ marks how much of each window has passed · ↻ time until reset</Text>
150 </Box>
151 ) : null}
152 </Box>
153 )
154
155 return below ? (
156 <Box flexDirection="column">
157 {card}
158 {below}
159 </Box>
160 ) : (
161 card
162 )
163 })
164}
165hooks/format.ts 87 lines1import type { Limit } from '../types'
2
3const HOUR = 3_600_000
4
5const WINDOWS: Record<string, { label: string; short: string; ms?: number }> = {
6 five_hour: { label: '5-hour', short: '5h', ms: 5 * HOUR },
7 seven_day: { label: 'Weekly', short: 'wk', ms: 7 * 24 * HOUR },
8 spend_limit: { label: 'Spend', short: 'spend' },
9}
10
11export function label(kind: string): string {
12 return WINDOWS[kind]?.label ?? kind
13}
14
15export function short(kind: string): string {
16 return WINDOWS[kind]?.short ?? kind
17}
18
19export function tone(percent: number): 'success' | 'warning' | 'error' {
20 return percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
21}
22
23export function until(resetsAt: string | undefined, now: number): string {
24 if (!resetsAt) return ''
25 const ms = Date.parse(resetsAt) - now
26 if (!Number.isFinite(ms) || ms <= 0) return 'now'
27 const mins = Math.round(ms / 60_000)
28 if (mins < 60) return `${mins}m`
29 const hours = Math.floor(mins / 60)
30 if (hours < 24) return `${hours}h ${String(mins % 60).padStart(2, '0')}m`
31 return `${Math.floor(hours / 24)}d ${hours % 24}h`
32}
33
34/** How far through its window a limit is, 0 to 100, or undefined when unknown. */
35export function elapsed(l: Limit, now: number): number | undefined {
36 const ms = WINDOWS[l.kind]?.ms
37 if (!ms || !l.resetsAt) return undefined
38 const left = Date.parse(l.resetsAt) - now
39 if (!Number.isFinite(left)) return undefined
40 return Math.max(0, Math.min(100, 100 - (left / ms) * 100))
41}
42
43export type Cell = { glyph: string; kind: 'fill' | 'empty' | 'pace' }
44
45/** The meter's cells: filled to `percent`, with a pace tick at `pace` when known. */
46export function meter(percent: number, width: number, pace?: number): Cell[] {
47 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
48 const tick = pace === undefined ? -1 : Math.min(width - 1, Math.floor((pace / 100) * width))
49 return Array.from({ length: width }, (_, i): Cell =>
50 i === tick ? { glyph: '│', kind: 'pace' } : i < filled ? { glyph: '█', kind: 'fill' } : { glyph: '░', kind: 'empty' },
51 )
52}
53
54/** Runs of equal-kind cells, so a meter draws as a few Text spans. */
55export function runs(cells: Cell[]): { kind: Cell['kind']; text: string }[] {
56 const out: { kind: Cell['kind']; text: string }[] = []
57 for (const c of cells) {
58 const last = out[out.length - 1]
59 if (last && last.kind === c.kind) last.text += c.glyph
60 else out.push({ kind: c.kind, text: c.glyph })
61 }
62 return out
63}
64
65export function pace(percent: number, elapsedPct: number | undefined): 'ahead' | 'behind' | 'on pace' | undefined {
66 if (elapsedPct === undefined) return undefined
67 const diff = percent - elapsedPct
68 return diff > 5 ? 'ahead' : diff < -5 ? 'behind' : 'on pace'
69}
70
71export function crossed(
72 before: number | undefined,
73 after: number | undefined,
74 marks: number[],
75): number | undefined {
76 if (after === undefined) return undefined
77 return [...marks].sort((a, b) => b - a).find(m => after >= m && (before ?? 0) < m)
78}
79
80export const TONE_HEX: Record<ReturnType<typeof tone>, string> = {
81 success: '#3fb37f',
82 warning: '#e0a43a',
83 error: '#e5534b',
84}
85export const TRACK = '#8b949e'
86
87types/index.d.ts 10 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { limits: Limit[]; usd?: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'plan-usage': { usage: Usage | null; isHidden: boolean }
8 }
9}
10