SLOPSHOPPER

cc-status-line

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

newbandspinnercommandtoaststatus
v0.1.0MITupdated 2026-10-13mikljohansson/cc-status-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-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 ⎿ cc-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

cc-status-line

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
SegmentMeaning
verkeLast folder of the directory you started Claude Code in (~ for your home folder)
Opus 5.5 xhighActive model (1M added when the extended context window is on) and its thinking effort, shown once the first turn has run
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/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:

  • 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 382 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: '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}
382
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 20 lines
1export 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