Directory, model, context and 5h/weekly usage with local 24h reset times, under the prompt.

A Claude Code mod that shows one line under the prompt:
verke │ Opus 5.5 │ ctx ▰▱▱▱▱ 21% │ 5h ▰▰▱▱▱ 35% ↻ 14:20 / wk ▰▰▰▱▱ 51% (F 12%) ⇥ Wed 19:54 ↻ Sat 01:00 │ user@example.com
| Segment | Meaning |
|---|---|
verke | Last folder of the directory you started Claude Code in (~ for your home folder) |
Opus 5.5 | Active model (1M added when the extended context window is on) |
ctx | How full the context window is |
5h / wk | How much of your 5-hour and weekly limits you have used |
(F 12%) | How much of your weekly Fable limit you have used (left out when your plan has none) |
↻ 14:20 | When that limit resets, in your local time zone, 24h clock. A weekday is added when the reset is on another day |
⇥ Wed 19:54 | Only shown when you are on pace to use the whole limit before it resets: the time you would run out |
user@example.com | The account you are signed in with (left out when not signed in, such as with an API key) |
Colours: green under 50%, yellow from 50%, orange from 75%, bold red from 90% (context: 50 / 65 / 80%). The ⇥ time is coloured by how much of the window you would be locked out before the reset: yellow under 10%, orange from 10%, bold red over 30%. You get one toast per window when a limit passes 90%.
Run /quota to show details above the prompt: the full project path, longer bars, time-zone name, time left until each reset, and the pace forecast in words. Run /quota again to hide them.
It is a plugin of function hooks (a "mod"), not a statusLine script. Most figures come from what Claude Code already receives with each reply. The Fable weekly limit isn't among them, so the mod reads it from the usage endpoint every 5 minutes through $.session.authorize(). Claude Code adds the credential to that request itself, so the mod never sees your token.
The pace forecast is a straight-line projection: the share of the limit used so far divided by the share of the window that has passed. It stays hidden for the first 10% of a window, where the estimate is too noisy.
In Claude Code:
/plugin marketplace add mikljohansson/claude-code-status-line
/plugin install status-line@claude-code-status-line
Or clone the repository and start Claude Code with claude --plugin-dir <path to the clone>.
Requirements:
Both appear in /config under the plugin:
Europe/Berlin. Empty uses the system zone.en (Mon) or de (Mo).claude plugin validate .
claude plugin test .
Inspired by TahaSabir0/Best-ClaudeCode-statusline (the segment layout and colour levels) and anantraghunath/claude-code-usage-quota-mod (the pace forecast and the 90% warning).
MIT
hooks/register.tsx 367 lines1import { atom, read, update } from 'claude-code'
2import type {
3 BoxProps,
4 ElementConstructor,
5 EngineInterface,
6 Register,
7 SessionContextUsage,
8 SessionRateLimit,
9 TextProps,
10} from 'claude-code'
11
12import type { QuotaLimit, QuotaSnap } from '../types'
13import {
14 COLORS,
15 contextLevel,
16 duration,
17 fableLimit,
18 filledCells,
19 makeClock,
20 modelName,
21 pace,
22 pct,
23 lastSegment,
24 runOutLevel,
25 shortDir,
26 tokensK,
27 usageLevel,
28 windowOf,
29 type Clock,
30 type Level,
31} from './format'
32
33const snap = atom({ plugin: 'status-line', key: 'snap' } as const, null)
34const isExpanded = atom({ plugin: 'status-line', key: 'isExpanded' } as const, false)
35const clockNow = atom({ plugin: 'status-line', key: 'now' } as const, 0)
36const userEmail = atom({ plugin: 'status-line', key: 'user' } as const, null)
37const fableAtom = atom({ plugin: 'status-line', key: 'fable' } as const, null)
38
39const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'wk' }
40const LONG_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'weekly', fable: 'Fable' }
41const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
42const FABLE_EVERY = 5 * 60_000
43const NARROW_BELOW = 90
44const WARNED_KEY = 'warned'
45const EMAIL = /[^\s@]+@[^\s@]+\.[a-z]{2,}/i
46
47type Options = { timeZone?: string; weekdays?: 'en' | 'de' }
48
49async function refresh($: EngineInterface, clock: Clock, measured?: { context: SessionContextUsage; rateLimits: SessionRateLimit[] }) {
50 const usage = measured ?? (await $.session.usage())
51 const [root, model, profile, home] = await Promise.all([
52 $.session.root(),
53 $.session.model(),
54 $.env.get('USERPROFILE'),
55 $.env.get('HOME'),
56 ])
57 const ctx = usage.context
58 const limits: QuotaLimit[] = usage.rateLimits
59 .filter(l => l.kind in LABELS)
60 .map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : undefined }))
61 const next: QuotaSnap = {
62 dir: lastSegment(root, profile ?? home),
63 path: shortDir(root, profile ?? home),
64 model: modelName(model),
65 context: ctx.percent === undefined ? undefined : { percent: ctx.percent, tokens: ctx.tokens, window: ctx.window },
66 limits,
67 fable: (await read($, fableAtom)) ?? undefined,
68 }
69 const now = await $.clock.now()
70 await update($, snap, () => next)
71 await update($, clockNow, () => now)
72 // The band covers terminal and desktop; a pinned status line there would only repeat it as a notice.
73 const surfaces = await $.session.surfaces()
74 const hasBand = surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
75 $.ui.status(hasBand ? undefined : statusText(clock, next, now, await read($, userEmail)))
76 await warnPast90($, clock, next.fable ? [...limits, next.fable] : limits)
77}
78
79// The Fable weekly window is not among the engine's rate limits; the usage endpoint has it.
80// The host holds the credential and sets the header itself; the token never reaches this plugin.
81async function refreshFable($: EngineInterface) {
82 try {
83 const auth = await $.session.authorize()
84 if (auth?.kind !== 'bearer') return
85 const res = await $.http.fetch(USAGE_URL, {
86 auth: auth.handle,
87 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
88 })
89 if (!res.ok) return
90 const fable = fableLimit(JSON.parse(res.text)) ?? null
91 await update($, fableAtom, () => fable)
92 await update($, snap, s => (s ? { ...s, fable: fable ?? undefined } : s))
93 } catch {
94 // Keep the last reading; the next poll tries again.
95 }
96}
97
98function statusText(clock: Clock, s: QuotaSnap, now: number, user: string | null): string {
99 const parts = [s.dir, s.model]
100 if (s.context) parts.push(`ctx ${Math.round(s.context.percent)}%`)
101 for (const l of s.limits) {
102 let text = `${LABELS[l.kind]} ${Math.round(l.percent)}%`
103 if (l.resetsAt) {
104 const p = pace(l.percent, l.resetsAt, windowOf(l.kind), now)
105 if (p.runOutAt) text += ` ⇥ ${clock.when(p.runOutAt, now)}`
106 text += ` ↻ ${clock.when(l.resetsAt, now)}`
107 }
108 if (l.kind === 'seven_day' && s.fable) text += ` (F ${Math.round(s.fable.percent)}%)`
109 parts.push(text)
110 }
111 if (user) parts.push(user)
112 return parts.join(' │ ')
113}
114
115// The prompt.context block arrives only with the first prompt; until then, the
116// account Claude Code keeps in its global config (no tokens live in that file).
117async function readAccountEmail($: EngineInterface): Promise<string | null> {
118 const [configDir, profile, home] = await Promise.all([
119 $.env.get('CLAUDE_CONFIG_DIR'),
120 $.env.get('USERPROFILE'),
121 $.env.get('HOME'),
122 ])
123 const dir = configDir ?? profile ?? home
124 if (!dir) return null
125 try {
126 const config = JSON.parse(await $.fs.read(`${dir}/.claude.json`))
127 const email = config?.oauthAccount?.emailAddress
128 return typeof email === 'string' && email ? email : null
129 } catch {
130 return null
131 }
132}
133
134// One toast per window per reset, remembered across sessions.
135async function warnPast90($: EngineInterface, clock: Clock, limits: QuotaLimit[]) {
136 const stored = await $.store.get(WARNED_KEY)
137 const warned = Array.isArray(stored) ? (stored as string[]) : []
138 const fresh = limits.filter(l => l.percent >= 90 && !warned.includes(`${l.kind}@${l.resetsAt ?? ''}`))
139 if (fresh.length === 0) return
140 const now = await $.clock.now()
141 for (const l of fresh) {
142 const reset = l.resetsAt ? `, resets ${clock.when(l.resetsAt, now)}` : ''
143 $.ui.toast(`${LONG_LABELS[l.kind]} limit at ${Math.round(l.percent)}%${reset}`)
144 }
145 const all = [...warned, ...fresh.map(l => `${l.kind}@${l.resetsAt ?? ''}`)].slice(-10)
146 await $.store.set(WARNED_KEY, all)
147}
148
149export const register: Register = (on, options) => {
150 const opts = (options ?? {}) as Options
151 const clock: Clock = makeClock(opts.timeZone?.trim() || undefined, opts.weekdays === 'de' ? 'de' : 'en')
152
153 // The signed-in account's email, read from the context block the engine gives the model.
154 on('prompt.context', async ($, e, next) => {
155 const result = await next(e)
156 const block = result.blocks.find(b => b.name === 'userEmail')
157 const email = block ? (EMAIL.exec(block.text)?.[0] ?? null) : null
158 await update($, userEmail, () => email)
159 return result
160 })
161
162 on('session.start', async ($, e, next) => {
163 const result = await next(e)
164 await $.command.register({ name: 'quota', description: 'Show or hide usage details above the prompt.' })
165 if (!(await read($, userEmail))) {
166 const email = await readAccountEmail($)
167 if (email) await update($, userEmail, () => email)
168 }
169 await refreshFable($)
170 await refresh($, clock)
171 $.clock.every(30_000, () => refresh($, clock))
172 $.clock.every(FABLE_EVERY, () => refreshFable($))
173 return result
174 })
175
176 on('session.measure', async ($, e, next) => {
177 const result = await next(e)
178 await refresh($, clock, e)
179 return result
180 })
181
182 on('command.run', { command: 'quota' }, async $ => {
183 const open = await update($, isExpanded, v => !v)
184 return { text: open ? 'Usage details shown above the prompt.' : 'Usage details hidden.' }
185 })
186
187 // The compact row, drawn on its own line just above the engine's hint line under the prompt.
188 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
189 const engineLine = await next(e)
190 const s = await read($, snap)
191 if (!s) return engineLine
192 const now = (await read($, clockNow)) || (await $.clock.now())
193 const user = await read($, userEmail)
194 const columns = e.viewport?.columns ?? 120
195 const { Box, Text } = $.ui.resolve(e)
196 return (
197 <Box flexDirection="column">
198 {drawRow({ Box, Text }, clock, s, now, columns, user)}
199 {engineLine}
200 </Box>
201 )
202 })
203
204 // The details, above the prompt, only while /quota has them open.
205 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206 if (e.props.hasSurvey) return next(e)
207 const s = await read($, snap)
208 if (!s || !(await read($, isExpanded))) return next(e)
209 const now = (await read($, clockNow)) || (await $.clock.now())
210 const { Box, Text, Button } = $.ui.resolve(e)
211 return (
212 <Box flexDirection="column">
213 {drawDetails({ Box, Text }, clock, s, now, e.props.bodyColumns)}
214 {/* Clicks reach a Button only in the fullscreen layout; elsewhere /quota is the way to hide. */}
215 <Box flexDirection="row">
216 {e.viewport?.isFullscreen ? (
217 <Button key="hide" plain dimColor label="hide details" onPress={() => update($, isExpanded, () => false)} />
218 ) : (
219 <Text dimColor>/quota to hide</Text>
220 )}
221 </Box>
222 </Box>
223 )
224 })
225}
226
227type Kit = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
228
229function bar({ Box, Text }: Kit, key: string, p: number, level: Level, cells: number, tickAt?: number) {
230 const full = filledCells(p, cells)
231 const showTick = tickAt !== undefined && tickAt >= full && tickAt < cells
232 const before = showTick ? tickAt - full : cells - full
233 const after = showTick ? cells - tickAt - 1 : 0
234 return (
235 <Box key={key} flexDirection="row">
236 <Text color={COLORS[level]}>{'▰'.repeat(full)}</Text>
237 <Text dimColor>{'▱'.repeat(before)}</Text>
238 {showTick ? <Text color="#9aa5a0">┆</Text> : null}
239 {showTick ? <Text dimColor>{'▱'.repeat(after)}</Text> : null}
240 </Box>
241 )
242}
243
244function percentText({ Text }: Kit, key: string, p: number, level: Level) {
245 return <Text key={key} color={COLORS[level]} bold={level === 'crit'}>{pct(p)}</Text>
246}
247
248// " ⇥ 16:37", coloured by how long you would be locked out before the reset.
249function runOutText({ Text }: Kit, clock: Clock, runOutAt: number, resetsAt: number, windowMs: number, now: number) {
250 const level = runOutLevel(runOutAt, resetsAt, windowMs)
251 return <Text color={COLORS[level]} bold={level === 'crit'}> ⇥ {clock.when(runOutAt, now)}</Text>
252}
253
254// " (F 12%)" after the weekly figure.
255function fableText({ Box, Text }: Kit, key: string, f: QuotaLimit) {
256 const level = usageLevel(f.percent)
257 return (
258 <Box key={key} flexDirection="row">
259 <Text dimColor> (</Text>
260 <Text color={COLORS[level]} bold={level === 'crit'}>F {Math.round(f.percent)}%</Text>
261 <Text dimColor>)</Text>
262 </Box>
263 )
264}
265
266// "verke │ Opus 5.5 │ ctx ▰▱▱▱▱ 23% │ 5h ▰▰▱▱▱ 31% ↻ 14:20 / wk ▰▰▰▱▱ 50% (F 12%) ⇥ Wed 21:48 ↻ Sat 01:00 │ user@example.com"
267// Under NARROW_BELOW columns the bars drop out.
268function drawRow(kit: Kit, clock: Clock, s: QuotaSnap, now: number, columns: number, user: string | null) {
269 const { Box, Text } = kit
270 const withBars = columns >= NARROW_BELOW
271 const sep = (key: string, glyph = ' │ ') => <Text key={key} dimColor>{glyph}</Text>
272 const ctx = s.context
273 const ctxLevel = ctx ? contextLevel(ctx.percent) : 'ok'
274 const limits = s.limits.flatMap((l, i) => {
275 const level = usageLevel(l.percent)
276 const p = l.resetsAt ? pace(l.percent, l.resetsAt, windowOf(l.kind), now) : undefined
277 return [
278 i > 0 ? sep(`ls${i}`, ' / ') : null,
279 <Box key={l.kind} flexDirection="row">
280 <Text dimColor>{LABELS[l.kind]} </Text>
281 {withBars ? bar(kit, 'bar', l.percent, level, 5) : null}
282 {percentText(kit, 'pct', l.percent, level)}
283 {l.kind === 'seven_day' && s.fable ? fableText(kit, 'fable', s.fable) : null}
284 {p?.runOutAt && l.resetsAt ? runOutText(kit, clock, p.runOutAt, l.resetsAt, windowOf(l.kind), now) : null}
285 {l.resetsAt ? <Text dimColor> ↻ {clock.when(l.resetsAt, now)}</Text> : null}
286 </Box>,
287 ]
288 })
289 return (
290 <Box key="quota-row" flexDirection="row" flexWrap="wrap">
291 <Text bold>{s.dir}</Text>
292 {sep('s1')}
293 <Text>{s.model}</Text>
294 {ctx ? sep('s2') : null}
295 {ctx ? <Text dimColor>ctx </Text> : null}
296 {ctx && withBars ? bar(kit, 'ctxb', ctx.percent, ctxLevel, 5) : null}
297 {ctx ? percentText(kit, 'ctxp', ctx.percent, ctxLevel) : null}
298 {s.limits.length > 0 ? sep('s3') : null}
299 {limits}
300 {user ? sep('s4') : null}
301 {user ? <Text dimColor>{user}</Text> : null}
302 </Box>
303 )
304}
305
306function drawDetails(kit: Kit, clock: Clock, s: QuotaSnap, now: number, columns: number) {
307 const { Box, Text } = kit
308 const cells = columns < NARROW_BELOW ? 10 : 20
309 const label = (text: string) => <Text dimColor>{text.padEnd(9)}</Text>
310 const ctx = s.context
311 const ctxLevel = ctx ? contextLevel(ctx.percent) : 'ok'
312 return (
313 <Box key="quota-details" flexDirection="column">
314 <Box flexDirection="row">
315 {label('project')}
316 <Text wrap="truncate-start">{s.path}</Text>
317 </Box>
318 {ctx ? (
319 <Box flexDirection="row">
320 {label('context')}
321 {bar(kit, 'b', ctx.percent, ctxLevel, cells)}
322 {percentText(kit, 'p', ctx.percent, ctxLevel)}
323 {ctx.tokens !== undefined ? (
324 <Text dimColor> {tokensK(ctx.tokens)}/{tokensK(ctx.window)} tokens</Text>
325 ) : null}
326 </Box>
327 ) : null}
328 {(s.fable ? [...s.limits, s.fable] : s.limits).flatMap(l => {
329 const level = usageLevel(l.percent)
330 const p = l.resetsAt ? pace(l.percent, l.resetsAt, windowOf(l.kind), now) : undefined
331 const tickAt = p ? Math.min(cells - 1, Math.floor(p.elapsed * cells)) : undefined
332 const rows = [
333 <Box key={`d-${l.kind}`} flexDirection="row">
334 {label(LONG_LABELS[l.kind] ?? l.kind)}
335 {bar(kit, 'b', l.percent, level, cells, tickAt)}
336 {percentText(kit, 'p', l.percent, level)}
337 {l.resetsAt ? (
338 <Text dimColor>
339 {' '}resets {clock.when(l.resetsAt, now)} {clock.zone(l.resetsAt)} (in {duration(l.resetsAt - now)})
340 </Text>
341 ) : null}
342 </Box>,
343 ]
344 if (p?.runOutAt && l.resetsAt) {
345 const runOut = runOutLevel(p.runOutAt, l.resetsAt, windowOf(l.kind))
346 rows.push(
347 <Box key={`f-${l.kind}`} flexDirection="row">
348 {label('')}
349 <Text color={COLORS[runOut]} bold={runOut === 'crit'}>
350 ⇥ {clock.when(p.runOutAt, now)} {clock.zone(p.runOutAt)}: on pace for ~{p.projected}%, you run out before the reset
351 </Text>
352 </Box>,
353 )
354 } else if (p?.projected !== undefined) {
355 rows.push(
356 <Box key={`f-${l.kind}`} flexDirection="row">
357 {label('')}
358 <Text dimColor>on pace for about {p.projected}% by reset</Text>
359 </Box>,
360 )
361 }
362 return rows
363 })}
364 </Box>
365 )
366}
367hooks/format.ts 135 lines1// Pure helpers: levels, bars, pace forecast, local time and path formatting.
2
3export type Level = 'ok' | 'warn' | 'hot' | 'crit'
4
5export const COLORS: Record<Level, string> = {
6 ok: '#4fb37e',
7 warn: '#d9b43c',
8 hot: '#e3843a',
9 crit: '#e2554f',
10}
11
12export const usageLevel = (p: number): Level =>
13 p >= 90 ? 'crit' : p >= 75 ? 'hot' : p >= 50 ? 'warn' : 'ok'
14
15export const contextLevel = (p: number): Level =>
16 p >= 80 ? 'crit' : p >= 65 ? 'hot' : p >= 50 ? 'warn' : 'ok'
17
18// How much of the window you would spend locked out, between running out and the reset.
19export const runOutLevel = (runOutAt: number, resetsAt: number, windowMs: number): Level => {
20 const lockedOut = (resetsAt - runOutAt) / windowMs
21 return lockedOut > 0.3 ? 'crit' : lockedOut >= 0.1 ? 'hot' : 'warn'
22}
23
24export const filledCells = (percent: number, cells: number) =>
25 Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
26
27const HOUR = 3_600_000
28export const WINDOW_MS: Record<string, number> = {
29 five_hour: 5 * HOUR,
30 seven_day: 7 * 24 * HOUR,
31 fable: 7 * 24 * HOUR,
32}
33
34export const windowOf = (kind: string) => WINDOW_MS[kind] ?? 5 * HOUR
35
36export type Pace = { elapsed: number; projected?: number; runOutAt?: number }
37
38// Straight-line projection from the share of the window used so far.
39// Nothing before 10% of the window has passed: too noisy to be useful.
40export function pace(percent: number, resetsAt: number, windowMs: number, now: number): Pace {
41 const start = resetsAt - windowMs
42 const elapsed = Math.max(0, Math.min(windowMs, now - start))
43 if (elapsed < windowMs * 0.1 || percent <= 0) return { elapsed: elapsed / windowMs }
44 const projected = Math.round((percent * windowMs) / elapsed)
45 const runOutAt = start + (elapsed * 100) / percent
46 return runOutAt < resetsAt
47 ? { elapsed: elapsed / windowMs, projected, runOutAt }
48 : { elapsed: elapsed / windowMs, projected }
49}
50
51export type Clock = {
52 /** 24h HH:MM, prefixed with a weekday when not the same local day as `now`. */
53 when: (ms: number, now: number) => string
54 /** Zone abbreviation at that instant, such as CEST or CET. */
55 zone: (ms: number) => string
56}
57
58export function makeClock(timeZone: string | undefined, weekdays: 'en' | 'de'): Clock {
59 const tz = timeZone || undefined
60 const locale = weekdays === 'de' ? 'de-DE' : 'en-GB'
61 const time = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
62 const day = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })
63 const weekday = new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: 'short' })
64 const zoneFmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, timeZoneName: 'short' })
65 return {
66 when: (ms, now) => {
67 const hm = time.format(ms)
68 if (day.format(ms) === day.format(now)) return hm
69 return `${weekday.format(ms).replace(/\.$/, '')} ${hm}`
70 },
71 zone: ms => {
72 const name = zoneFmt.formatToParts(ms).find(p => p.type === 'timeZoneName')?.value ?? ''
73 return ZONE_NAMES[name] ?? name
74 },
75 }
76}
77
78// en-GB spells some zones as offsets; give Central Europe its usual names.
79const ZONE_NAMES: Record<string, string> = { 'GMT+1': 'CET', 'GMT+2': 'CEST' }
80
81// The last folder of a path; the home folder itself is "~".
82export function lastSegment(path: string, home: string | undefined): string {
83 const trimmed = path.replace(/[\\/]+$/, '')
84 if (home && trimmed.toLowerCase() === home.replace(/[\\/]+$/, '').toLowerCase()) return '~'
85 return trimmed.split(/[\\/]/).pop() || trimmed
86}
87
88export function shortDir(cwd: string, home: string | undefined): string {
89 const sep = cwd.includes('\\') ? '\\' : '/'
90 let path = cwd
91 if (home && path.toLowerCase().startsWith(home.toLowerCase())) {
92 const rest = path.slice(home.length)
93 if (rest === '' || rest[0] === '\\' || rest[0] === '/') path = '~' + rest
94 }
95 const parts = path.split(/[\\/]+/).filter((p, i) => p !== '' || i === 0)
96 if (parts.length <= 4) return parts.join(sep) || sep
97 return [parts[0], '…', ...parts.slice(-2)].join(sep)
98}
99
100// "claude-opus-5-5[1m]" -> "Opus 5.5 1M"; a name already readable passes through.
101export function modelName(raw: string): string {
102 const isLong = /\[1m\]/i.test(raw)
103 const id = raw.replace(/\[1m\]/i, '').trim()
104 const m = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(id)
105 const [, family = '', major = '', minor] = m ?? []
106 const name = m ? `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor ? '.' + minor : ''}` : id
107 return isLong ? `${name} 1M` : name
108}
109
110// The Fable row of /api/oauth/usage: a `weekly_scoped` entry of `limits` scoped to that model.
111// `percent` is 0-100; `resets_at` an ISO string or epoch seconds.
112export function fableLimit(body: unknown): { kind: 'fable'; percent: number; resetsAt?: number } | undefined {
113 const limits = (body as { limits?: unknown } | null)?.limits
114 if (!Array.isArray(limits)) return undefined
115 const row = limits.find(
116 l => l?.kind === 'weekly_scoped' && String(l?.scope?.model?.display_name ?? '').toLowerCase() === 'fable',
117 )
118 if (!row || typeof row.percent !== 'number') return undefined
119 const r = row.resets_at
120 const resetsAt = typeof r === 'number' ? r * 1000 : typeof r === 'string' ? Date.parse(r) : NaN
121 return { kind: 'fable', percent: row.percent, resetsAt: Number.isFinite(resetsAt) ? resetsAt : undefined }
122}
123
124export const pct = (p: number) => `${Math.round(p)}%`.padStart(4)
125
126export const tokensK = (n: number) => (n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
127
128export const duration = (ms: number) => {
129 const min = Math.max(0, Math.round(ms / 60_000))
130 const d = Math.floor(min / 1440)
131 const h = Math.floor((min % 1440) / 60)
132 const m = min % 60
133 return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
134}
135types/index.d.ts 18 lines1export type QuotaLimit = { kind: string; percent: number; resetsAt?: number }
2
3export type QuotaSnap = {
4 dir: string
5 path: string
6 model: string
7 context?: { percent: number; tokens?: number; window: number }
8 limits: QuotaLimit[]
9 /** The Fable weekly limit, read from the usage endpoint; absent when unknown. */
10 fable?: QuotaLimit
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'status-line': { snap: QuotaSnap | null; isExpanded: boolean; now: number; fable: QuotaLimit | null; user: string | null }
16 }
17}
18