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 xhigh │ 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 xhigh | Active model (1M added when the extended context window is on) and its thinking effort, shown once the first turn has run |
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/cc-status-line
/plugin install cc-status-line@cc-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 382 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: 'cc-status-line', key: 'snap' } as const, null)
34const isExpanded = atom({ plugin: 'cc-status-line', key: 'isExpanded' } as const, false)
35const clockNow = atom({ plugin: 'cc-status-line', key: 'now' } as const, 0)
36const userEmail = atom({ plugin: 'cc-status-line', key: 'user' } as const, null)
37const fableAtom = atom({ plugin: 'cc-status-line', key: 'fable' } as const, null)
38const effortAtom = atom({ plugin: 'cc-status-line', key: 'effort' } as const, null)
39
40const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'wk' }
41const LONG_LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'weekly', fable: 'Fable' }
42const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
43const FABLE_EVERY = 5 * 60_000
44const NARROW_BELOW = 90
45const WARNED_KEY = 'warned'
46const EMAIL = /[^\s@]+@[^\s@]+\.[a-z]{2,}/i
47
48type Options = { timeZone?: string; weekdays?: 'en' | 'de' }
49
50async function refresh($: EngineInterface, clock: Clock, measured?: { context: SessionContextUsage; rateLimits: SessionRateLimit[] }) {
51 const usage = measured ?? (await $.session.usage())
52 const [root, model, profile, home] = await Promise.all([
53 $.session.root(),
54 $.session.model(),
55 $.env.get('USERPROFILE'),
56 $.env.get('HOME'),
57 ])
58 const ctx = usage.context
59 const limits: QuotaLimit[] = usage.rateLimits
60 .filter(l => l.kind in LABELS)
61 .map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : undefined }))
62 const next: QuotaSnap = {
63 dir: lastSegment(root, profile ?? home),
64 path: shortDir(root, profile ?? home),
65 model: modelName(model),
66 effort: (await read($, effortAtom)) ?? undefined,
67 context: ctx.percent === undefined ? undefined : { percent: ctx.percent, tokens: ctx.tokens, window: ctx.window },
68 limits,
69 fable: (await read($, fableAtom)) ?? undefined,
70 }
71 const now = await $.clock.now()
72 await update($, snap, () => next)
73 await update($, clockNow, () => now)
74 // The band covers terminal and desktop; a pinned status line there would only repeat it as a notice.
75 const surfaces = await $.session.surfaces()
76 const hasBand = surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
77 $.ui.status(hasBand ? undefined : statusText(clock, next, now, await read($, userEmail)))
78 await warnPast90($, clock, next.fable ? [...limits, next.fable] : limits)
79}
80
81// The Fable weekly window is not among the engine's rate limits; the usage endpoint has it.
82// The host holds the credential and sets the header itself; the token never reaches this plugin.
83async function refreshFable($: EngineInterface) {
84 try {
85 const auth = await $.session.authorize()
86 if (auth?.kind !== 'bearer') return
87 const res = await $.http.fetch(USAGE_URL, {
88 auth: auth.handle,
89 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
90 })
91 if (!res.ok) return
92 const fable = fableLimit(JSON.parse(res.text)) ?? null
93 await update($, fableAtom, () => fable)
94 await update($, snap, s => (s ? { ...s, fable: fable ?? undefined } : s))
95 } catch {
96 // Keep the last reading; the next poll tries again.
97 }
98}
99
100function statusText(clock: Clock, s: QuotaSnap, now: number, user: string | null): string {
101 const parts = [s.dir, s.effort ? `${s.model} ${s.effort}` : s.model]
102 if (s.context) parts.push(`ctx ${Math.round(s.context.percent)}%`)
103 for (const l of s.limits) {
104 let text = `${LABELS[l.kind]} ${Math.round(l.percent)}%`
105 if (l.resetsAt) {
106 const p = pace(l.percent, l.resetsAt, windowOf(l.kind), now)
107 if (p.runOutAt) text += ` ⇥ ${clock.when(p.runOutAt, now)}`
108 text += ` ↻ ${clock.when(l.resetsAt, now)}`
109 }
110 if (l.kind === 'seven_day' && s.fable) text += ` (F ${Math.round(s.fable.percent)}%)`
111 parts.push(text)
112 }
113 if (user) parts.push(user)
114 return parts.join(' │ ')
115}
116
117// The prompt.context block arrives only with the first prompt; until then, the
118// account Claude Code keeps in its global config (no tokens live in that file).
119async function readAccountEmail($: EngineInterface): Promise<string | null> {
120 const [configDir, profile, home] = await Promise.all([
121 $.env.get('CLAUDE_CONFIG_DIR'),
122 $.env.get('USERPROFILE'),
123 $.env.get('HOME'),
124 ])
125 const dir = configDir ?? profile ?? home
126 if (!dir) return null
127 try {
128 const config = JSON.parse(await $.fs.read(`${dir}/.claude.json`))
129 const email = config?.oauthAccount?.emailAddress
130 return typeof email === 'string' && email ? email : null
131 } catch {
132 return null
133 }
134}
135
136// One toast per window per reset, remembered across sessions.
137async function warnPast90($: EngineInterface, clock: Clock, limits: QuotaLimit[]) {
138 const stored = await $.store.get(WARNED_KEY)
139 const warned = Array.isArray(stored) ? (stored as string[]) : []
140 const fresh = limits.filter(l => l.percent >= 90 && !warned.includes(`${l.kind}@${l.resetsAt ?? ''}`))
141 if (fresh.length === 0) return
142 const now = await $.clock.now()
143 for (const l of fresh) {
144 const reset = l.resetsAt ? `, resets ${clock.when(l.resetsAt, now)}` : ''
145 $.ui.toast(`${LONG_LABELS[l.kind]} limit at ${Math.round(l.percent)}%${reset}`)
146 }
147 const all = [...warned, ...fresh.map(l => `${l.kind}@${l.resetsAt ?? ''}`)].slice(-10)
148 await $.store.set(WARNED_KEY, all)
149}
150
151export const register: Register = (on, options) => {
152 const opts = (options ?? {}) as Options
153 const clock: Clock = makeClock(opts.timeZone?.trim() || undefined, opts.weekdays === 'de' ? 'de' : 'en')
154
155 // The signed-in account's email, read from the context block the engine gives the model.
156 on('prompt.context', async ($, e, next) => {
157 const result = await next(e)
158 const block = result.blocks.find(b => b.name === 'userEmail')
159 const email = block ? (EMAIL.exec(block.text)?.[0] ?? null) : null
160 await update($, userEmail, () => email)
161 return result
162 })
163
164 on('session.start', async ($, e, next) => {
165 const result = await next(e)
166 await $.command.register({ name: 'quota', description: 'Show or hide usage details above the prompt.' })
167 if (!(await read($, userEmail))) {
168 const email = await readAccountEmail($)
169 if (email) await update($, userEmail, () => email)
170 }
171 await refreshFable($)
172 await refresh($, clock)
173 $.clock.every(30_000, () => refresh($, clock))
174 $.clock.every(FABLE_EVERY, () => refreshFable($))
175 return result
176 })
177
178 // The session has no effort getter; each main-loop model request carries the level in force.
179 on('turn.step', async function* ($, e, next) {
180 if (!e.agentId) {
181 const effort = e.effort === undefined ? null : String(e.effort)
182 if (effort !== (await read($, effortAtom))) {
183 await update($, effortAtom, () => effort)
184 await update($, snap, s => (s ? { ...s, effort: effort ?? undefined } : s))
185 }
186 }
187 return yield* next(e)
188 })
189
190 on('session.measure', async ($, e, next) => {
191 const result = await next(e)
192 await refresh($, clock, e)
193 return result
194 })
195
196 on('command.run', { command: 'quota' }, async $ => {
197 const open = await update($, isExpanded, v => !v)
198 return { text: open ? 'Usage details shown above the prompt.' : 'Usage details hidden.' }
199 })
200
201 // The compact row, drawn on its own line just above the engine's hint line under the prompt.
202 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
203 const engineLine = await next(e)
204 const s = await read($, snap)
205 if (!s) return engineLine
206 const now = (await read($, clockNow)) || (await $.clock.now())
207 const user = await read($, userEmail)
208 const columns = e.viewport?.columns ?? 120
209 const { Box, Text } = $.ui.resolve(e)
210 return (
211 <Box flexDirection="column">
212 {drawRow({ Box, Text }, clock, s, now, columns, user)}
213 {engineLine}
214 </Box>
215 )
216 })
217
218 // The details, above the prompt, only while /quota has them open.
219 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
220 if (e.props.hasSurvey) return next(e)
221 const s = await read($, snap)
222 if (!s || !(await read($, isExpanded))) return next(e)
223 const now = (await read($, clockNow)) || (await $.clock.now())
224 const { Box, Text, Button } = $.ui.resolve(e)
225 return (
226 <Box flexDirection="column">
227 {drawDetails({ Box, Text }, clock, s, now, e.props.bodyColumns)}
228 {/* Clicks reach a Button only in the fullscreen layout; elsewhere /quota is the way to hide. */}
229 <Box flexDirection="row">
230 {e.viewport?.isFullscreen ? (
231 <Button key="hide" plain dimColor label="hide details" onPress={() => update($, isExpanded, () => false)} />
232 ) : (
233 <Text dimColor>/quota to hide</Text>
234 )}
235 </Box>
236 </Box>
237 )
238 })
239}
240
241type Kit = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
242
243function bar({ Box, Text }: Kit, key: string, p: number, level: Level, cells: number, tickAt?: number) {
244 const full = filledCells(p, cells)
245 const showTick = tickAt !== undefined && tickAt >= full && tickAt < cells
246 const before = showTick ? tickAt - full : cells - full
247 const after = showTick ? cells - tickAt - 1 : 0
248 return (
249 <Box key={key} flexDirection="row">
250 <Text color={COLORS[level]}>{'▰'.repeat(full)}</Text>
251 <Text dimColor>{'▱'.repeat(before)}</Text>
252 {showTick ? <Text color="#9aa5a0">┆</Text> : null}
253 {showTick ? <Text dimColor>{'▱'.repeat(after)}</Text> : null}
254 </Box>
255 )
256}
257
258function percentText({ Text }: Kit, key: string, p: number, level: Level) {
259 return <Text key={key} color={COLORS[level]} bold={level === 'crit'}>{pct(p)}</Text>
260}
261
262// " ⇥ 16:37", coloured by how long you would be locked out before the reset.
263function runOutText({ Text }: Kit, clock: Clock, runOutAt: number, resetsAt: number, windowMs: number, now: number) {
264 const level = runOutLevel(runOutAt, resetsAt, windowMs)
265 return <Text color={COLORS[level]} bold={level === 'crit'}> ⇥ {clock.when(runOutAt, now)}</Text>
266}
267
268// " (F 12%)" after the weekly figure.
269function fableText({ Box, Text }: Kit, key: string, f: QuotaLimit) {
270 const level = usageLevel(f.percent)
271 return (
272 <Box key={key} flexDirection="row">
273 <Text dimColor> (</Text>
274 <Text color={COLORS[level]} bold={level === 'crit'}>F {Math.round(f.percent)}%</Text>
275 <Text dimColor>)</Text>
276 </Box>
277 )
278}
279
280// "verke │ Opus 5.5 xhigh │ ctx ▰▱▱▱▱ 23% │ 5h ▰▰▱▱▱ 31% ↻ 14:20 / wk ▰▰▰▱▱ 50% (F 12%) ⇥ Wed 21:48 ↻ Sat 01:00 │ user@example.com"
281// Under NARROW_BELOW columns the bars drop out.
282function drawRow(kit: Kit, clock: Clock, s: QuotaSnap, now: number, columns: number, user: string | null) {
283 const { Box, Text } = kit
284 const withBars = columns >= NARROW_BELOW
285 const sep = (key: string, glyph = ' │ ') => <Text key={key} dimColor>{glyph}</Text>
286 const ctx = s.context
287 const ctxLevel = ctx ? contextLevel(ctx.percent) : 'ok'
288 const limits = s.limits.flatMap((l, i) => {
289 const level = usageLevel(l.percent)
290 const p = l.resetsAt ? pace(l.percent, l.resetsAt, windowOf(l.kind), now) : undefined
291 return [
292 i > 0 ? sep(`ls${i}`, ' / ') : null,
293 <Box key={l.kind} flexDirection="row">
294 <Text dimColor>{LABELS[l.kind]} </Text>
295 {withBars ? bar(kit, 'bar', l.percent, level, 5) : null}
296 {percentText(kit, 'pct', l.percent, level)}
297 {l.kind === 'seven_day' && s.fable ? fableText(kit, 'fable', s.fable) : null}
298 {p?.runOutAt && l.resetsAt ? runOutText(kit, clock, p.runOutAt, l.resetsAt, windowOf(l.kind), now) : null}
299 {l.resetsAt ? <Text dimColor> ↻ {clock.when(l.resetsAt, now)}</Text> : null}
300 </Box>,
301 ]
302 })
303 return (
304 <Box key="quota-row" flexDirection="row" flexWrap="wrap">
305 <Text bold>{s.dir}</Text>
306 {sep('s1')}
307 <Text>{s.model}</Text>
308 {s.effort ? <Text dimColor> {s.effort}</Text> : null}
309 {ctx ? sep('s2') : null}
310 {ctx ? <Text dimColor>ctx </Text> : null}
311 {ctx && withBars ? bar(kit, 'ctxb', ctx.percent, ctxLevel, 5) : null}
312 {ctx ? percentText(kit, 'ctxp', ctx.percent, ctxLevel) : null}
313 {s.limits.length > 0 ? sep('s3') : null}
314 {limits}
315 {user ? sep('s4') : null}
316 {user ? <Text dimColor>{user}</Text> : null}
317 </Box>
318 )
319}
320
321function drawDetails(kit: Kit, clock: Clock, s: QuotaSnap, now: number, columns: number) {
322 const { Box, Text } = kit
323 const cells = columns < NARROW_BELOW ? 10 : 20
324 const label = (text: string) => <Text dimColor>{text.padEnd(9)}</Text>
325 const ctx = s.context
326 const ctxLevel = ctx ? contextLevel(ctx.percent) : 'ok'
327 return (
328 <Box key="quota-details" flexDirection="column">
329 <Box flexDirection="row">
330 {label('project')}
331 <Text wrap="truncate-start">{s.path}</Text>
332 </Box>
333 {ctx ? (
334 <Box flexDirection="row">
335 {label('context')}
336 {bar(kit, 'b', ctx.percent, ctxLevel, cells)}
337 {percentText(kit, 'p', ctx.percent, ctxLevel)}
338 {ctx.tokens !== undefined ? (
339 <Text dimColor> {tokensK(ctx.tokens)}/{tokensK(ctx.window)} tokens</Text>
340 ) : null}
341 </Box>
342 ) : null}
343 {(s.fable ? [...s.limits, s.fable] : s.limits).flatMap(l => {
344 const level = usageLevel(l.percent)
345 const p = l.resetsAt ? pace(l.percent, l.resetsAt, windowOf(l.kind), now) : undefined
346 const tickAt = p ? Math.min(cells - 1, Math.floor(p.elapsed * cells)) : undefined
347 const rows = [
348 <Box key={`d-${l.kind}`} flexDirection="row">
349 {label(LONG_LABELS[l.kind] ?? l.kind)}
350 {bar(kit, 'b', l.percent, level, cells, tickAt)}
351 {percentText(kit, 'p', l.percent, level)}
352 {l.resetsAt ? (
353 <Text dimColor>
354 {' '}resets {clock.when(l.resetsAt, now)} {clock.zone(l.resetsAt)} (in {duration(l.resetsAt - now)})
355 </Text>
356 ) : null}
357 </Box>,
358 ]
359 if (p?.runOutAt && l.resetsAt) {
360 const runOut = runOutLevel(p.runOutAt, l.resetsAt, windowOf(l.kind))
361 rows.push(
362 <Box key={`f-${l.kind}`} flexDirection="row">
363 {label('')}
364 <Text color={COLORS[runOut]} bold={runOut === 'crit'}>
365 ⇥ {clock.when(p.runOutAt, now)} {clock.zone(p.runOutAt)}: on pace for ~{p.projected}%, you run out before the reset
366 </Text>
367 </Box>,
368 )
369 } else if (p?.projected !== undefined) {
370 rows.push(
371 <Box key={`f-${l.kind}`} flexDirection="row">
372 {label('')}
373 <Text dimColor>on pace for about {p.projected}% by reset</Text>
374 </Box>,
375 )
376 }
377 return rows
378 })}
379 </Box>
380 )
381}
382hooks/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 20 lines1export type QuotaLimit = { kind: string; percent: number; resetsAt?: number }
2
3export type QuotaSnap = {
4 dir: string
5 path: string
6 model: string
7 /** The reasoning effort of the main loop's last model request, e.g. `xhigh`; absent until a turn has run or for a model without effort. */
8 effort?: string
9 context?: { percent: number; tokens?: number; window: number }
10 limits: QuotaLimit[]
11 /** The Fable weekly limit, read from the usage endpoint; absent when unknown. */
12 fable?: QuotaLimit
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'cc-status-line': { snap: QuotaSnap | null; isExpanded: boolean; now: number; fable: QuotaLimit | null; user: string | null; effort: string | null }
18 }
19}
20