A band above the prompt: a pace bar and a forecast for the 5-hour and weekly plan limits.

A Claude Code mod that draws your plan's usage limits just above the prompt, as a pace bar and a forecast: will the limit last until it refills, and if not, when does it run dry?

One row per limit: the 5-hour window and the weekly one.
┃ is the clock: how far through the window you are. Fill past the post means you are spending faster than the limit refills.on track for about 54% by Mon 2:00pm, or runs dry at 1:20pm, 1h 40m before the 3:00pm refill.The forecast assumes you keep going at your average pace so far in the window. In the first tenth of a window it says too early to tell instead.
Inside Claude Code:
/plugin marketplace add Triyambak-CA/claude-mods
/plugin install pace-meter@triyambak-mods
Then start a new session. The band appears once Claude Code has a usage reading, usually straight away, otherwise after your first reply.
date +%z. Where that command is missing (Windows), times show in UTC.Claude Code pushes the usage figures to the mod after each reply (session.measure); the mod keeps them in its state and draws the rows in the band above the prompt (ui.render on AbovePrompt). A one-minute timer moves the clock, so the post and the forecast keep moving while you are idle.
hooks/register.tsx: the hooks.hooks/pace.ts: the arithmetic, as plain data. At the refill you will have used used x window / elapsed; above 100% it runs dry (100 - used) x elapsed / used seconds from now.claude plugin validate .
claude plugin test .
15 checks: the rows for running dry, on track, landing exactly on 100%, a run-dry time after midnight, the widest row (93 columns), too early, at the cap and already reset; plus the band itself through Claude Code's engine on terminal and desktop, the one-minute clock, and stepping aside for a survey.
MIT, see the repository's LICENSE.
hooks/register.tsx 77 lines1// pace-meter: a band above the Claude Code prompt with a pace bar and a forecast
2// for the plan's 5-hour and weekly limits.
3//
4// 5-hour ██████┃██▋░░░░░░ 60% runs dry at 1:20pm, 1h 40m before the 3:00pm refill
5// weekly ████▊░░░┃░░░░░░░ 30% on track for about 54% by Mon 2:00pm
6//
7// The bar fills with the share used; the white post is the clock, how far through
8// the window it is. The engine pushes the figures through `session.measure`; a
9// one-minute timer moves the clock so the post and the sentence advance while idle.
10// Nothing shows until the first reading (a fresh session, or an API key).
11
12import { atom, read, update } from 'claude-code'
13import type { Register } from 'claude-code'
14
15import type { Limit } from '../types'
16import { paceRows, parseOffset } from './pace'
17
18const limitsAtom = atom({ plugin: 'pace-meter', key: 'limits' } as const, [] as Limit[])
19const nowAtom = atom({ plugin: 'pace-meter', key: 'now' } as const, 0)
20const offsetAtom = atom({ plugin: 'pace-meter', key: 'offset' } as const, 0)
21
22const KINDS = new Set(['five_hour', 'seven_day'])
23const keep = (limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] =>
24 limits.filter(l => KINDS.has(l.kind)).map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
25
26export const register: Register = on => {
27 on('session.start', async ($, e, next) => {
28 const result = await next(e)
29
30 // The sandbox has no time zone; ask the Mac once.
31 const zone = await $.process.run(['date', '+%z']).catch(() => undefined)
32 const offset = (zone && zone.exitCode === 0 && parseOffset(zone.stdout)) || 0
33 await update($, offsetAtom, () => offset)
34
35 // A reload mid-session still has the last reading.
36 const usage = await $.session.usage()
37 await update($, limitsAtom, () => keep(usage.rateLimits))
38 const now = await $.clock.now()
39 await update($, nowAtom, () => now)
40
41 $.clock.every(60_000, () => {
42 void $.clock.now().then(t => update($, nowAtom, () => t))
43 })
44 return result
45 })
46
47 on('session.measure', async ($, e, next) => {
48 if (e.changed.includes('rateLimits')) {
49 await update($, limitsAtom, () => keep(e.rateLimits))
50 const now = await $.clock.now()
51 await update($, nowAtom, () => now)
52 }
53 return next(e)
54 })
55
56 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
57 if (e.props.hasSurvey) return next(e)
58 const rows = paceRows(await read($, limitsAtom), await read($, nowAtom), await read($, offsetAtom))
59 if (rows.length === 0) return next(e)
60
61 const { Box, Text } = $.ui.resolve(e)
62 return (
63 <Box flexDirection="column">
64 {rows.map(row => (
65 <Box flexDirection="row">
66 {row.runs.map((run, i) => (
67 <Text color={run.color} bold={run.bold} wrap={i === row.runs.length - 1 ? 'truncate-end' : 'truncate'}>
68 {run.text}
69 </Text>
70 ))}
71 </Box>
72 ))}
73 </Box>
74 )
75 })
76}
77hooks/pace.ts 136 lines1// The pace bar and forecast, as plain data. It began as a jq status line script and
2// was ported line for line, so both draw the same rows for the same figures
3// (tests/pace.test.ts pins those rows).
4//
5// at refill = used x window / elapsed above 100% it runs dry, and when:
6// runs dry = (100 - used) x elapsed / used seconds from now
7//
8// Times are local: the sandbox has no time zone, so callers pass the Mac's UTC
9// offset in seconds and every clock is read with UTC getters after adding it.
10
11import type { Limit } from '../types'
12
13export type Run = { text: string; color: string; bold?: boolean }
14export type State = 'reset' | 'cap' | 'early' | 'dry' | 'tight' | 'easy'
15export type Row = { kind: string; state: State; runs: Run[]; words: string }
16
17export const COLOR = {
18 fg: '#E6E6E6', dim: '#707070', track: '#3E3E3E',
19 mint: '#99FFE4', amber: '#FFC799', coral: '#FF8A8A', post: '#FFFFFF',
20} as const
21
22const WINDOW: Record<string, number> = { five_hour: 18000, seven_day: 604800 }
23const LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly' }
24const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
25const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
26
27const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x))
28
29function wall(sec: number, offset: number) {
30 const d = new Date((sec + offset) * 1000)
31 return { day: d.getUTCDay(), date: d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(),
32 h: d.getUTCHours(), m: d.getUTCMinutes() }
33}
34function clock(sec: number, offset: number) {
35 const w = wall(sec, offset)
36 return `${w.h % 12 || 12}:${String(w.m).padStart(2, '0')}${w.h >= 12 ? 'pm' : 'am'}`
37}
38const dayClock = (sec: number, offset: number) => `${DAYS[wall(sec, offset).day]} ${clock(sec, offset)}`
39/** A moment told after another: the day only when its date differs from that one. */
40const after = (sec: number, ref: number, offset: number) =>
41 wall(sec, offset).date === wall(ref, offset).date ? clock(sec, offset) : dayClock(sec, offset)
42
43function span(seconds: number) {
44 const s = Math.floor(seconds)
45 if (s < 60) return '<1m'
46 if (s >= 86400) return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`
47 if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
48 return `${Math.floor(s / 60)}m`
49}
50
51/** Green under half, amber to 80, coral from there: how close the share used is to the cap. */
52const tone = (pct: number) => (pct >= 80 ? COLOR.coral : pct >= 50 ? COLOR.amber : COLOR.mint)
53
54/** "+0530" (from `date +%z`) as seconds east of UTC; undefined when it does not parse. */
55export function parseOffset(text: string): number | undefined {
56 const m = /^([+-])(\d{2})(\d{2})/.exec(text.trim())
57 if (!m) return undefined
58 return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 3600 + Number(m[3]) * 60)
59}
60
61/** One window's row, or null for a window this band does not draw (a gateway spend limit). */
62export function paceRow(limit: Limit, nowSec: number, offset: number): Row | null {
63 const len = WINDOW[limit.kind]
64 const label = LABEL[limit.kind]
65 const resetsAt = limit.resetsAt === undefined ? NaN : Math.floor(Date.parse(limit.resetsAt) / 1000)
66 if (len === undefined || label === undefined || !Number.isFinite(resetsAt)) return null
67
68 const u = limit.percentUsed
69 const t = Math.floor(nowSec)
70 const left = resetsAt - t
71 const elapsed = len - left
72 const ef = elapsed / len
73 const fill = clamp(Math.floor((u * 128) / 100 + 0.5), 0, 128)
74 const post = clamp(Math.floor(ef * 16), 0, 15)
75 const refill = limit.kind === 'seven_day' ? dayClock(resetsAt, offset) : after(resetsAt, t, offset)
76
77 let state: State
78 let words: string
79 if (left <= 0) {
80 state = 'reset'; words = 'refilled · updates after the next reply'
81 } else if (u >= 100) {
82 state = 'cap'; words = `at the cap · refills ${refill}`
83 } else if (ef < 0.1) {
84 state = 'early'; words = `too early to tell · refills ${refill}`
85 } else {
86 const atRefill = (u * len) / elapsed
87 if (atRefill > 100) {
88 const dry = ((100 - u) * elapsed) / u
89 state = 'dry'
90 words = `runs dry at ${after(t + dry, t, offset)}, ${span(left - dry)} before the ${after(resetsAt, t + dry, offset)} refill`
91 } else {
92 state = atRefill >= 85 ? 'tight' : 'easy'
93 words = `on track for about ${Math.round(atRefill)}% by ${refill}`
94 }
95 }
96
97 const fillColor = { dry: COLOR.coral, cap: COLOR.coral, tight: COLOR.amber, easy: COLOR.mint,
98 early: tone(Math.round(u)), reset: COLOR.track }[state]
99 const pct = state === 'reset' ? '--' : state === 'cap' ? '100%' : `${Math.round(u)}%`
100 const full = fill >> 3
101 const part = fill & 7
102
103 const cells: Run[] = [{ text: label.padEnd(8), color: COLOR.fg }]
104 for (let i = 0; i < 16; i++) {
105 if (state !== 'reset' && i === post) cells.push({ text: '┃', color: COLOR.post, bold: true })
106 else if (state !== 'reset' && i < full) cells.push({ text: '█', color: fillColor })
107 else if (state !== 'reset' && i === full && part > 0) cells.push({ text: EIGHTHS[part] ?? '', color: fillColor })
108 else cells.push({ text: '░', color: COLOR.track })
109 }
110 cells.push({ text: ' ' + pct.padStart(4), color: state === 'reset' ? COLOR.dim : tone(Math.round(u)) })
111 cells.push({ text: ' ', color: COLOR.fg })
112 cells.push({ text: words, color: state === 'early' || state === 'reset' ? COLOR.dim : fillColor })
113
114 // merge neighbours of one look, so a row is a handful of Text runs, not 20
115 const runs: Run[] = []
116 for (const c of cells) {
117 const last = runs[runs.length - 1]
118 if (last && last.color === c.color && !!last.bold === !!c.bold) last.text += c.text
119 else runs.push({ ...c })
120 }
121 return { kind: limit.kind, state, runs, words }
122}
123
124/** The rows to draw, 5-hour first; empty when there is nothing to show yet. */
125export function paceRows(limits: readonly Limit[], nowMs: number, offset: number): Row[] {
126 const order = ['five_hour', 'seven_day']
127 return order
128 .map(kind => limits.find(l => l.kind === kind))
129 .filter((l): l is Limit => l !== undefined)
130 .map(l => paceRow(l, nowMs / 1000, offset))
131 .filter((r): r is Row => r !== null)
132}
133
134/** A row's text with the colours removed, for tests and for logs. */
135export const plain = (row: Row) => row.runs.map(r => r.text).join('')
136types/index.d.ts 16 lines1/** One plan window as the engine reports it (SessionRateLimit), kept as received. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'pace-meter': {
7 /** The 5-hour and weekly windows from the latest measurement, 5-hour first. */
8 limits: Limit[]
9 /** Epoch milliseconds the band draws against; a timer moves it every minute. */
10 now: number
11 /** The Mac's offset from UTC in seconds (+19800 in India), read once at start. */
12 offset: number
13 }
14 }
15}
16