SLOPSHOPPER

usage-bar

Shows 5-hour and weekly usage limits as bars above the prompt, with reset times and a weekly pace forecast

newbandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bar
› 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 › /usage-bar ⎿ usage-bar: Usage bars hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

My DotFiles

Source 3 files
hooks/register.tsx 112 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionRateLimit } from 'claude-code'
3
4import type { Limit, Limits } from '../types'
5import { FIVE_HOURS, WEEK, bar, colorFor, duration, forecast } from './forecast'
6
7const limits = atom({ plugin: 'usage-bar', key: 'limits' } as const, {
8  fiveHour: null,
9  weekly: null,
10})
11const isVisible = atom({ plugin: 'usage-bar', key: 'isVisible' } as const, true)
12const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
13
14const pick = (rateLimits: SessionRateLimit[], kind: string): Limit | null => {
15  const found = rateLimits.find(one => one.kind === kind)
16
17  return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt } : null
18}
19
20const toLimits = (rateLimits: SessionRateLimit[]): Limits => ({
21  fiveHour: pick(rateLimits, 'five_hour'),
22  weekly: pick(rateLimits, 'seven_day'),
23})
24
25export const register: Register = on => {
26  on('session.start', async ($, e, next) => {
27    await $.command.register({
28      name: 'usage-bar',
29      description: 'Toggle the usage limit bars above the prompt',
30      immediate: true,
31    })
32
33    const usage = await $.session.usage()
34    const t = await $.clock.now()
35    await update($, limits, () => toLimits(usage.rateLimits))
36    await update($, now, () => t)
37
38    // Keeps the "resets in" countdowns fresh between turns.
39    $.clock.every(60_000, () => {
40      void $.clock.now().then(t => update($, now, () => t))
41    })
42
43    return next(e)
44  })
45
46  on('session.measure', async ($, e, next) => {
47    if (e.changed.includes('rateLimits')) {
48      const t = await $.clock.now()
49      await update($, limits, () => toLimits(e.rateLimits))
50      await update($, now, () => t)
51    }
52
53    return next(e)
54  })
55
56  on('command.run', { command: 'usage-bar' }, async $ => {
57    const shown = await update($, isVisible, v => !v)
58
59    return { text: shown ? 'Usage bars shown.' : 'Usage bars hidden.' }
60  })
61
62  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63    const { fiveHour, weekly } = await read($, limits)
64
65    if (e.props.hasSurvey || !(await read($, isVisible)) || (!fiveHour && !weekly)) {
66      return next(e)
67    }
68
69    const { Box, Text } = $.ui.resolve(e)
70    const t = (await read($, now)) || (await $.clock.now())
71    const width = Math.max(10, Math.min(30, e.props.bodyColumns - 50))
72
73    const row = (label: string, limit: Limit | null, windowMs: number, withForecast: boolean) => {
74      if (!limit) {
75        return (
76          <Text key={label} dimColor>
77            {label} no reading yet
78          </Text>
79        )
80      }
81
82      const resets = limit.resetsAt
83        ? `resets in ${duration(Date.parse(limit.resetsAt) - t)}`
84        : ''
85      const pace = withForecast ? forecast(limit, windowMs, t) : { kind: 'unknown' as const }
86
87      return (
88        <Box key={label} flexDirection="row">
89          <Text>{label} </Text>
90          <Text color={colorFor(limit.percentUsed)}>{bar(limit.percentUsed, width)}</Text>
91          <Text> {limit.percentUsed.toFixed(0).padStart(3)}% </Text>
92          <Text dimColor>{resets}</Text>
93          {pace.kind === 'ok' && <Text dimColor> · on pace for ~{pace.atReset.toFixed(0)}%</Text>}
94          {pace.kind === 'exceeds' && (
95            <Text color="red" bold>
96              {' '}
97              ⚠ on pace to hit limit in {duration(pace.hitsAt - t)}
98            </Text>
99          )}
100        </Box>
101      )
102    }
103
104    return (
105      <Box flexDirection="column">
106        {row('5h  ', fiveHour, FIVE_HOURS, false)}
107        {row('week', weekly, WEEK, true)}
108      </Box>
109    )
110  })
111}
112
hooks/forecast.ts 60 lines
1import type { Limit } from '../types'
2
3export const FIVE_HOURS = 5 * 60 * 60 * 1000
4export const WEEK = 7 * 24 * 60 * 60 * 1000
5
6export type Forecast =
7  | { kind: 'unknown' }
8  | { kind: 'ok'; atReset: number }
9  | { kind: 'exceeds'; hitsAt: number }
10
11// Linear pace over the elapsed part of the window: the rate so far, carried to reset.
12export const forecast = (limit: Limit, windowMs: number, now: number): Forecast => {
13  if (!limit.resetsAt) {
14    return { kind: 'unknown' }
15  }
16
17  const resetsAt = Date.parse(limit.resetsAt)
18  const elapsed = windowMs - (resetsAt - now)
19
20  // Too early in the window for the pace to mean anything.
21  if (elapsed < windowMs * 0.02 || limit.percentUsed <= 0) {
22    return { kind: 'unknown' }
23  }
24
25  const perMs = limit.percentUsed / elapsed
26  const atReset = limit.percentUsed + perMs * (resetsAt - now)
27
28  if (limit.percentUsed >= 100 || atReset > 100) {
29    return { kind: 'exceeds', hitsAt: now + Math.max(0, (100 - limit.percentUsed) / perMs) }
30  }
31
32  return { kind: 'ok', atReset }
33}
34
35export const duration = (ms: number): string => {
36  const minutes = Math.max(0, Math.round(ms / 60000))
37  const days = Math.floor(minutes / 1440)
38  const hours = Math.floor((minutes % 1440) / 60)
39  const mins = minutes % 60
40
41  if (days > 0) {
42    return `${days}d ${hours}h`
43  }
44
45  if (hours > 0) {
46    return `${hours}h ${mins}m`
47  }
48
49  return `${mins}m`
50}
51
52export const bar = (percent: number, width: number): string => {
53  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
54
55  return '█'.repeat(filled) + '░'.repeat(width - filled)
56}
57
58export const colorFor = (percent: number): string =>
59  percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green'
60
types/index.d.ts 10 lines
1export type Limit = { percentUsed: number; resetsAt?: string }
2
3export type Limits = { fiveHour: Limit | null; weekly: Limit | null }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'usage-bar': { limits: Limits; isVisible: boolean; now: number }
8  }
9}
10