SLOPSHOPPER

status-line

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

newbandspinnercommandtoaststatus
v0.1.0MITupdated 2026-10-05mikljohansson/claude-code-status-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-line
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quota ⎿ status-line: Usage details shown above the prompt. project /work/app context ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% 97k/200k tokens 5h ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% hide details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › app │ Opus 5.5 │ ctx ▰▰▱▱▱ 49% │ 5h ▰▰▱▱▱ 31% ⟨Claude Code's own drawing⟩

Draws

Band
project /work/app context ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% 97k/200k tokens 5h ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% hide details
Prompt hint
app │ Opus 5.5 │ ctx ▰▰▱▱▱ 49% │ 5h ▰▰▱▱▱ 31% ⟨Claude Code's own drawing⟩
README

claude-code-status-line

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
SegmentMeaning
verkeLast folder of the directory you started Claude Code in (~ for your home folder)
Opus 5.5Active model (1M added when the extended context window is on)
ctxHow full the context window is
5h / wkHow 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:20When that limit resets, in your local time zone, 24h clock. A weekday is added when the reset is on another day
⇥ Wed 19:54Only shown when you are on pace to use the whole limit before it resets: the time you would run out
user@example.comThe 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.

How it works

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.

Install

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:

  • A Claude Code build with function-hook plugins (developed on 2.1.289). This plugin API is early access and may change between releases.
  • A Pro or Max plan for the 5h and weekly figures. With an API key those segments are left out.

Settings

Both appear in /config under the plugin:

  • Time zone: an IANA zone such as Europe/Berlin. Empty uses the system zone.
  • Weekday names: en (Mon) or de (Mo).

Development

claude plugin validate .
claude plugin test .

Credits

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).

License

MIT

Source 3 files
hooks/register.tsx 367 lines
1import { 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}
367
hooks/format.ts 135 lines
1// 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}
135
types/index.d.ts 18 lines
1export 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