/pace: what this chat has used, and when your 5-hour and weekly limits run out at your current pace.

What it does: shows what this chat has used, and when your 5-hour and weekly limits run out if you keep going at your current pace.
Where you see it:
/pace on the desktop: its footer slot is narrow and the limits and the timings button use it.The panel:
Pace updated 7:43p Close
Window Used Resets in Pace At this pace
5-hour ██████░░░░ 64% 2h13m +24%/h runs out ~9:13p, before the reset
Weekly ███████░░░ 76% 1d 4h +6%/day lasts until the reset
This chat
Tokens 10k fresh · 1.2M cached · 30k out
Cost $3.40 at API prices · 14 turns
Worked 1h42m over 12 prompts · each one in /timings
Used 5-hour +9% · weekly +2%
/cost counts it, and your turns. The rise in each window is counted between this chat's replies, so another chat working at the same time adds to it.Where the numbers come from: the readings the limits mod keeps in ~/.claude/mods-data/limits/readings.json, plus this chat's own replies. Without the limits mod the panel has no history to work from. The windows already warned about are in ~/.claude/mods-data/pace/warned.json.
Cost: no model calls.
hooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionRateLimit } from 'claude-code'
3
4import type { PaceSession } from '../types'
5
6// What this chat has used, and when your 5-hour and weekly limits run out at
7// your current pace, in a panel: /pace, or the P button in the footer (P! while
8// the pace would run a limit out before it resets). The pace is recent: the last
9// hour for the 5-hour window, the last day for the weekly one, worked out from the readings
10// the limits mod keeps in ~/.claude/mods-data/limits/readings.json. A toast
11// warns once per window when the pace would run a limit out before it resets.
12
13const PANE = 'pace'
14// A pace needs this much history in the window: a point or two of rise over a
15// short span (right after a reset, say) is no pace to project from.
16const FIVE = { lookbackMs: 3_600_000, minSpanMs: 20 * 60_000 } as const
17const WEEK = { lookbackMs: 24 * 3_600_000, minSpanMs: 6 * 3_600_000 } as const
18// Only a run-out at least this long before the reset is worth a warning.
19const WARN_MARGIN_MS = 5 * 60_000
20// Readings of one window name the same reset, give or take how the endpoint and the headers round it.
21const SAME_RESET_MS = 5 * 60_000
22const REFRESH_MS = 30_000
23
24const GREEN = '#4ade80'
25const AMBER = '#facc15'
26const RED = '#f87171'
27const LABEL = '#9ca3af'
28const DIM = '#71717a'
29const ACCENT = '#22d3ee'
30
31type Win = { pct: number; resetsAt: string | null }
32type Reading = { at: number; five: Win | null; week: Win | null }
33type Readings = { latest: Reading | null; history: Reading[] }
34type Kind = 'five' | 'week'
35
36/** Where a window stands and where it's heading. `perHour` null: too little history yet. */
37type Projection = { pct: number; resetsAt: number | null; perHour: number | null; runsOutAt: number | null; isBeforeReset: boolean }
38
39const EMPTY_SESSION: PaceSession = { tokensIn: 0, tokensCached: 0, tokensOut: 0, turns: 0, usd: null, fiveUp: 0, weekUp: 0 }
40
41const isOpen = atom({ plugin: 'pace', key: 'isOpen' } as const, false)
42const session = atom({ plugin: 'pace', key: 'session' } as const, EMPTY_SESSION)
43
44// The limits mod's readings, re-read every REFRESH_MS and after each reply.
45let readings: Readings = { latest: null, history: [] }
46// This chat's last reading of each window, to count how far it rose.
47let seen: Partial<Record<Kind, Win>> = {}
48
49// Claude's folder: CLAUDE_CONFIG_DIR when set (it may live on another drive), else ~/.claude
50const home = async ($: EngineInterface) => ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
51
52// This chat's working time and prompts, from the timings mod's file (none without it).
53const timingsOf = async ($: EngineInterface): Promise<{ workedMs: number; prompts: number } | null> => {
54 try {
55 const cwd = await $.session.cwd()
56 const main = cwd.replace(/[\/]\.claude[\/]worktrees[\/].*$/i, '')
57 const project = main.split(/[\/]/).filter(Boolean).pop() ?? main
58 const file = JSON.parse(await $.fs.read(`${await home($)}/mods-data/timings/${project}/${await $.session.id()}.json`)) as { prompts?: { durationMs: number }[] }
59 const prompts = file.prompts ?? []
60 return { workedMs: prompts.reduce((n, p) => n + p.durationMs, 0), prompts: prompts.length }
61 } catch {
62 return null
63 }
64}
65
66const loadReadings = async ($: EngineInterface) => {
67 try {
68 const raw = JSON.parse(await $.fs.read(`${await home($)}/mods-data/limits/readings.json`)) as Partial<Readings>
69 readings = { latest: raw.latest ?? null, history: Array.isArray(raw.history) ? raw.history : [] }
70 } catch {
71 readings = { latest: null, history: [] }
72 }
73}
74
75const timeOf = (iso: string | null) => (iso === null ? NaN : Date.parse(iso))
76const isSameWindow = (a: Win, b: Win) => Math.abs(timeOf(a.resetsAt) - timeOf(b.resetsAt)) < SAME_RESET_MS
77
78// 4:10p, 11p: local time, minutes left out on the hour; the weekday when it isn't today.
79const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
80const when = (t: number, now: number) => {
81 const d = new Date(Math.round(t / 60_000) * 60_000)
82 const h = d.getHours()
83 const m = d.getMinutes()
84 const clock = `${h % 12 || 12}${m === 0 ? '' : `:${String(m).padStart(2, '0')}`}${h < 12 ? 'a' : 'p'}`
85 return d.toDateString() === new Date(now).toDateString() ? clock : `${DAYS[d.getDay()]} ${clock}`
86}
87
88const project = (r: Readings, kind: Kind, now: number, span: { lookbackMs: number; minSpanMs: number }): Projection | null => {
89 const latest = r.latest?.[kind]
90 if (!r.latest || !latest) return null
91 const reset = timeOf(latest.resetsAt)
92 const resetsAt = Number.isFinite(reset) ? reset : null
93 // A window whose reset has passed has started over.
94 if (resetsAt !== null && resetsAt <= now) return { pct: 0, resetsAt: null, perHour: null, runsOutAt: null, isBeforeReset: false }
95 const first = r.history.find(h => h.at >= now - span.lookbackMs && h[kind] && isSameWindow(h[kind]!, latest))
96 const base = { pct: latest.pct, resetsAt, perHour: null, runsOutAt: null, isBeforeReset: false }
97 if (!first || r.latest.at - first.at < span.minSpanMs) return base
98 const perMs = (latest.pct - first[kind]!.pct) / (r.latest.at - first.at)
99 if (perMs <= 0) return { ...base, perHour: 0 }
100 const runsOutAt = r.latest.at + (100 - latest.pct) / perMs
101 return { ...base, perHour: perMs * 3_600_000, runsOutAt, isBeforeReset: resetsAt !== null && runsOutAt < resetsAt - WARN_MARGIN_MS }
102}
103
104const NAME: Record<Kind, string> = { five: '5-hour', week: 'weekly' }
105
106// Once per window, for every chat: the windows already warned about live beside the readings.
107const warnIfRunningOut = async ($: EngineInterface) => {
108 const now = await $.clock.now()
109 const file = `${await home($)}/mods-data/pace/warned.json`
110 let warned: string[] = []
111 try {
112 warned = (JSON.parse(await $.fs.read(file)) as { keys?: string[] }).keys ?? []
113 } catch {
114 warned = []
115 }
116 for (const [kind, span] of [['five', FIVE], ['week', WEEK]] as const) {
117 const p = project(readings, kind, now, span)
118 if (!p?.isBeforeReset || p.runsOutAt === null || p.resetsAt === null) continue
119 const key = `${kind}@${Math.round(p.resetsAt / 60_000)}`
120 if (warned.includes(key)) continue
121 warned = [...warned, key].slice(-20)
122 await $.fs.write(file, JSON.stringify({ keys: warned }))
123 $.ui.toast(`At this pace your ${NAME[kind]} limit runs out around ${when(p.runsOutAt, now)}, before it resets at ${when(p.resetsAt, now)}. /pace for details.`, { timeoutMs: 12_000 })
124 }
125}
126
127const togglePane = async ($: EngineInterface) => {
128 if ((await $.ui.panes()).some(p => p.id === PANE)) {
129 await $.ui.close({ id: PANE })
130 // A plugin's own close does not reach its own ui.close hook: reset the flag here.
131 await update($, isOpen, () => false)
132 return 'Pace closed.'
133 }
134 await loadReadings($)
135 const opened = await $.ui.open({ id: PANE, title: 'Pace' })
136 if (opened.isPlaced) await update($, isOpen, () => true)
137 else $.ui.toast(`The pace panel could not open (${opened.reason}).`)
138 return opened.isPlaced ? 'Pace opened.' : `Could not open the pane (${opened.reason}).`
139}
140
141// The rise of each window between this chat's readings, counted while the window stays the same.
142const tally = async ($: EngineInterface, limits: readonly SessionRateLimit[]) => {
143 const rise: Record<Kind, number> = { five: 0, week: 0 }
144 for (const [kind, name] of [['five', 'five_hour'], ['week', 'seven_day']] as const) {
145 const l = limits.find(x => x.kind === name)
146 if (!l) continue
147 const now: Win = { pct: l.percentUsed, resetsAt: l.resetsAt ?? null }
148 const before = seen[kind]
149 if (before && isSameWindow(before, now) && now.pct > before.pct) rise[kind] = now.pct - before.pct
150 seen = { ...seen, [kind]: now }
151 }
152 if (rise.five > 0 || rise.week > 0) {
153 await update($, session, s => ({ ...s, fiveUp: s.fiveUp + rise.five, weekUp: s.weekUp + rise.week }))
154 }
155}
156
157const tokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
158const pct = (n: number) => `${Math.round(n * 10) / 10}%`
159// How long until a time: 45m, 3h27m, 4d 3h.
160const left = (t: number, now: number) => {
161 const m = Math.max(0, Math.round((t - now) / 60_000))
162 if (m < 60) return `${m}m`
163 if (m < 24 * 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
164 return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
165}
166// A 10-cell bar of how much of a window is used.
167const bar = (n: number) => {
168 const full = Math.max(0, Math.min(10, Math.round(n / 10)))
169 return { used: '█'.repeat(full), rest: '░'.repeat(10 - full) }
170}
171
172export const register: Register = on => {
173 on('session.start', async ($, e, next) => {
174 await $.command.register({ name: 'pace', description: 'What this chat has used, and when your 5-hour and weekly limits run out at this pace' })
175 await loadReadings($)
176 $.clock.every(REFRESH_MS, () => {
177 void (async () => {
178 await loadReadings($)
179 // The panel and P (or P!) both draw from the readings.
180 $.ui.invalidate('ui.render')
181 })().catch(() => undefined)
182 })
183 return next(e)
184 })
185
186 on('command.run', { command: 'pace' }, async $ => ({ text: await togglePane($) }))
187
188 on('ui.close', { id: PANE }, async ($, e, next) => {
189 const done = await next(e)
190 await update($, isOpen, () => false)
191 return done
192 })
193
194 // Every reply, subagents' too: its tokens. Your turns: one each.
195 on('turn.complete', async ($, e, next) => {
196 const done = await next(e)
197 const u = done.usage ?? e.usage
198 if (u || e.agentId === undefined) {
199 await update($, session, s => ({
200 ...s,
201 tokensIn: s.tokensIn + (u ? u.input_tokens + u.cache_creation_input_tokens : 0),
202 tokensCached: s.tokensCached + (u?.cache_read_input_tokens ?? 0),
203 tokensOut: s.tokensOut + (u?.output_tokens ?? 0),
204 turns: s.turns + (e.agentId === undefined ? 1 : 0),
205 }))
206 }
207 return done
208 })
209
210 // The cost so far, how far the windows rose, and whether this pace runs one out.
211 on('session.measure', async ($, e, next) => {
212 const done = await next(e)
213 if (e.changed.includes('cost') && e.cost) {
214 const usd = e.cost.usd
215 await update($, session, s => ({ ...s, usd }))
216 }
217 if (e.changed.includes('rateLimits')) {
218 await tally($, e.rateLimits)
219 await loadReadings($)
220 await warnIfRunningOut($)
221 }
222 return done
223 })
224
225 // P in the footer, after the limits and before what the mods beneath add (the
226 // timings mod's T): a press opens or closes the panel; P! while the pace would
227 // run a limit out before it resets.
228 // The terminal's P after the limits line. The desktop's footer slot is narrow
229 // and the limits and the timings button use it: /pace opens the panel there.
230 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
231 if (e.surface === 'desktop') return next(e)
232 const below = await next(e)
233 const { Box, Button } = $.ui.resolve(e)
234 const now = await $.clock.now()
235 const isAtRisk = [project(readings, 'five', now, FIVE), project(readings, 'week', now, WEEK)].some(p => p?.isBeforeReset === true)
236 return (
237 <Box flexDirection="row" gap={1}>
238 <Button key="pace:p" label={isAtRisk ? '🔹P!' : '🔹P'} plain dimColor={!isAtRisk} onPress={() => togglePane($)} />
239 {below}
240 </Box>
241 )
242 })
243
244 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245 const { Box, Text, Button } = $.ui.resolve(e)
246 const s = await read($, session)
247 const now = await $.clock.now()
248 const timed = await timingsOf($)
249 const latest = readings.latest
250 const five = project(readings, 'five', now, FIVE)
251 const week = project(readings, 'week', now, WEEK)
252 const colorOf = (n: number) => (n >= 85 ? RED : n >= 60 ? AMBER : GREEN)
253
254 // The limits table: one row per window, fixed columns, the outlook takes the rest.
255 // Window Used Resets in Pace At this pace
256 // 5-hour ██████░░░░ 64% 2h13m +24%/h runs out ~9:13p
257 const cell = (key: string, width: number, child: RenderChildren) => (
258 <Box key={key} width={width} flexShrink={0}>
259 {child}
260 </Box>
261 )
262 const head = (
263 <Box key="head" flexDirection="row" gap={1}>
264 {cell('window', 8, <Text color={LABEL}>Window</Text>)}
265 {cell('used', 16, <Text color={LABEL}>Used</Text>)}
266 {cell('resets', 10, <Text color={LABEL}>Resets in</Text>)}
267 {cell('pace', 9, <Text color={LABEL}>Pace</Text>)}
268 <Text color={LABEL} wrap="truncate-end">
269 At this pace
270 </Text>
271 </Box>
272 )
273 const limitRow = (key: string, label: string, p: Projection | null, unit: 'h' | 'day') => {
274 if (!p) return null
275 const b = bar(p.pct)
276 const rate = p.perHour === null ? null : unit === 'h' ? p.perHour : p.perHour * 24
277 const outlook =
278 rate === null
279 ? { text: 'not enough history yet', color: DIM }
280 : rate <= 0 || p.runsOutAt === null
281 ? { text: 'not rising', color: DIM }
282 : p.isBeforeReset
283 ? { text: `runs out ~${when(p.runsOutAt, now)}, before the reset`, color: AMBER }
284 : { text: 'lasts until the reset', color: GREEN }
285 return (
286 <Box key={key} flexDirection="row" gap={1}>
287 {cell('window', 8, <Text bold>{label}</Text>)}
288 {cell(
289 'used',
290 16,
291 <Text>
292 <Text color={colorOf(p.pct)}>{b.used}</Text>
293 <Text color={DIM}>{b.rest}</Text>
294 <Text bold color={colorOf(p.pct)}>{` ${pct(p.pct)}`}</Text>
295 </Text>,
296 )}
297 {cell('resets', 10, <Text>{p.resetsAt === null ? '—' : left(p.resetsAt, now)}</Text>)}
298 {cell('pace', 9, <Text color={rate !== null && rate > 0 ? undefined : DIM}>{rate === null ? '—' : rate <= 0 ? '0' : `+${pct(rate)}/${unit}`}</Text>)}
299 <Text color={outlook.color} wrap="truncate-end">
300 {outlook.text}
301 </Text>
302 </Box>
303 )
304 }
305 // This chat's figures: a grey label column, the value beside it.
306 const detail = (key: string, label: string, value: RenderChildren) => (
307 <Box key={key} flexDirection="row" gap={1}>
308 {cell('label', 8, <Text color={LABEL}>{label}</Text>)}
309 <Box flexGrow={1} flexShrink={1} minWidth={0}>
310 {value}
311 </Box>
312 </Box>
313 )
314
315 return (
316 <Box flexDirection="column" gap={1}>
317 <Box flexDirection="row" gap={1}>
318 <Text bold color={ACCENT}>
319 Pace
320 </Text>
321 {latest && <Text color={DIM}>{`updated ${when(latest.at, now)}`}</Text>}
322 <Box flexGrow={1} />
323 <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
324 </Box>
325 {latest ? (
326 <Box flexDirection="column">
327 {head}
328 {limitRow('limit:five', '5-hour', five, 'h')}
329 {limitRow('limit:week', 'Weekly', week, 'day')}
330 </Box>
331 ) : (
332 <Text color={DIM}>No reading yet: the limits mod shows one after the first reply.</Text>
333 )}
334 <Box flexDirection="column">
335 <Text bold>This chat</Text>
336 {detail(
337 'tokens',
338 'Tokens',
339 <Text wrap="truncate-end">
340 {tokens(s.tokensIn)}
341 <Text color={LABEL}> fresh · </Text>
342 {tokens(s.tokensCached)}
343 <Text color={LABEL}> cached · </Text>
344 {tokens(s.tokensOut)}
345 <Text color={LABEL}> out</Text>
346 </Text>,
347 )}
348 {detail(
349 'cost',
350 'Cost',
351 <Text wrap="truncate-end">
352 {s.usd === null ? '$0.00' : `$${s.usd.toFixed(2)}`}
353 <Text color={LABEL}>{` at API prices · ${s.turns} ${s.turns === 1 ? 'turn' : 'turns'}`}</Text>
354 </Text>,
355 )}
356 {timed !== null && timed.prompts > 0 &&
357 detail(
358 'worked',
359 'Worked',
360 <Text wrap="truncate-end">
361 {left(timed.workedMs, 0)}
362 <Text color={LABEL}>{` over ${timed.prompts} ${timed.prompts === 1 ? 'prompt' : 'prompts'} · each one in /timings`}</Text>
363 </Text>,
364 )}
365 {detail(
366 'used',
367 'Used',
368 <Text wrap="truncate-end">
369 {`5-hour +${pct(s.fiveUp)}`}
370 <Text color={LABEL}> · </Text>
371 {`weekly +${pct(s.weekUp)}`}
372 </Text>,
373 )}
374 </Box>
375 <Text color={DIM}>
376 Pace: the 5-hour window's over the last hour, the weekly one's over the last day. "Used" counts any chat that ran at the same time.
377 </Text>
378 </Box>
379 )
380 })
381}
382types/index.d.ts 24 lines1/** What this chat has used so far. */
2export type PaceSession = {
3 /** Input tokens read fresh (cache writes included), read from the cache, and written out: every reply, subagents' too. */
4 tokensIn: number
5 tokensCached: number
6 tokensOut: number
7 /** Your turns in this chat. */
8 turns: number
9 /** Dollars, as /cost totals it; null before the first priced reply. */
10 usd: number | null
11 /** How far the 5-hour and weekly % rose between this chat's replies (other chats running meanwhile count too). */
12 fiveUp: number
13 weekUp: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 pace: {
19 isOpen: boolean
20 session: PaceSession
21 }
22 }
23}
24