SLOPSHOPPER

usage-limit

Shows 5h / 7d usage limits with reset countdowns above the prompt

newbandtoasttimer
v0.4.5no licenseupdated 2026-10-04shichang4fun/claude-mods/plugins/usage-limit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-limit
› 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 ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% ↻ NaNm (NaN:NaN) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% ↻ NaNm (NaN:NaN) ⟨Claude Code's own drawing⟩
README

claude-mods

Personal Claude Code mods, published as a local plugin marketplace.

Install

claude plugin marketplace add ~/Desktop/Github/claude-mods
claude plugin install usage-limit@claude-mods

Mods

  • usage-limit: a band above the prompt showing context-window fill, 5h / 7d usage limits with reset countdowns, and a warning when the 5h limit will be hit before it resets.
  • next-steps-desktop: after each turn, suggests up to three next prompts above the input. Click one to send it (desktop app) or draft it (terminal); "copy" puts it on the clipboard to edit first. Adapted from next-steps by Thariq Shihipar (MIT).
Source 2 files
hooks/register.tsx 363 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Sample } from '../types'
5
6const limits = atom({ plugin: 'usage-limit', key: 'limits' } as const, [])
7const contextPercent = atom({ plugin: 'usage-limit', key: 'contextPercent' } as const, null)
8// five_hour readings over the last SAMPLE_SPAN, for the time-to-limit forecast
9const samples = atom({ plugin: 'usage-limit', key: 'samples' } as const, [])
10
11const HOUR = 3_600_000
12const WINDOWS: Record<string, { label: string; ms?: number; showsClock?: boolean }> = {
13  five_hour: { label: '5h', ms: 5 * HOUR, showsClock: true },
14  seven_day: { label: '7d', ms: 7 * 24 * HOUR },
15  spend_limit: { label: '$' },
16}
17const WARN_AT = [80, 90]
18const SAMPLE_SPAN = 30 * 60_000
19const MIN_SPAN = 3 * 60_000
20// refreshes countdowns and the elapsed marker between measurements
21const TICK_MS = 60_000
22// reset clock times are shown in UTC+8
23const CLOCK_OFFSET = 8 * HOUR
24
25// Layout
26const BAR_CELLS = 10
27const METER_GAP = 3
28const SVG_COLORS: Record<string, string> = {
29  green: '#4caf50',
30  yellow: '#e0a526',
31  red: '#e5534b',
32  track: 'rgba(128,128,128,0.3)',
33}
34
35// value is the percentage; reset (countdown) and clock (reset time of day) are drawn in their own colors
36type Meter = {
37  key: string
38  label: string
39  used: number
40  elapsed: number | null
41  tone: string
42  value: string
43  reset: string | null
44  clock: string | null
45  warning: string | null
46}
47
48let ticker: { cancel: () => void } | null = null
49
50function clamp(p: number) {
51  return Math.min(100, Math.max(0, p))
52}
53
54// Pace: how far usage runs ahead of the share of the window already gone
55function toneOf(used: number, elapsed: number | null) {
56  if (used >= 90) return 'red'
57  if (elapsed === null) return used >= 80 ? 'red' : used >= 50 ? 'yellow' : 'green'
58  const margin = elapsed - used
59  if (margin < -15) return 'red'
60  if (margin < 10 && used >= 10) return 'yellow'
61  return 'green'
62}
63
64function duration(ms: number) {
65  const minutes = Math.max(0, Math.ceil(ms / 60_000))
66  const d = Math.floor(minutes / 1440)
67  const h = Math.floor((minutes % 1440) / 60)
68  const m = minutes % 60
69  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
70}
71
72function clock(ms: number) {
73  const t = new Date(ms + CLOCK_OFFSET)
74  return `${String(t.getUTCHours()).padStart(2, '0')}:${String(t.getUTCMinutes()).padStart(2, '0')}`
75}
76
77// ms until five_hour reaches 100% at the recent rate; null when flat or too few readings
78function timeToLimit(history: Sample[], percent: number) {
79  if (history.length < 2) return null
80  const first = history[0]
81  const last = history[history.length - 1]
82  const span = last.t - first.t
83  const rise = last.p - first.p
84  if (span < MIN_SPAN || rise <= 0) return null
85  return ((100 - percent) / rise) * span
86}
87
88async function recordSample($: EngineInterface, rateLimits: Limit[]) {
89  const five = rateLimits.find(l => l.kind === 'five_hour')
90  if (!five) return
91  const t = await $.clock.now()
92  await update($, samples, (prev: Sample[]) => {
93    const last = prev[prev.length - 1]
94    // a drop means the window reset: start over
95    const kept = last && five.percentUsed < last.p ? [] : prev.filter(s => t - s.t <= SAMPLE_SPAN)
96    return [...kept, { t, p: five.percentUsed }]
97  })
98}
99
100function limitMeter(limit: Limit, now: number, history: Sample[]): Meter {
101  const win = WINDOWS[limit.kind]
102  const label = win?.label ?? limit.kind
103  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : null
104  // the window has reset since the last reading: it starts again from zero
105  if (resetsAt !== null && resetsAt <= now) {
106    return { key: limit.kind, label, used: 0, elapsed: win?.ms ? 0 : null, tone: 'green', value: '0%', reset: null, clock: null, warning: null }
107  }
108  const resetIn = resetsAt === null ? null : resetsAt - now
109  const elapsed = win?.ms && resetIn !== null ? clamp(100 - (resetIn / win.ms) * 100) : null
110  const value = `${Math.round(limit.percentUsed)}%`
111  const reset = resetIn === null ? null : duration(resetIn)
112  const at = resetsAt !== null && win?.showsClock ? `(${clock(resetsAt)})` : null
113  const eta = limit.kind === 'five_hour' ? timeToLimit(history, limit.percentUsed) : null
114  // only worth showing when the limit would arrive before the reset
115  const warning = eta !== null && (resetIn === null || eta < resetIn) ? `⚠ ≈${duration(eta)} 撞线` : null
116  return { key: limit.kind, label, used: limit.percentUsed, elapsed, tone: toneOf(limit.percentUsed, elapsed), value, reset, clock: at, warning }
117}
118
119// Desktop chips: one SVG per meter (the band's Box has no rounded backgrounds), each a tinted
120// pill with an icon, label, thin bar, bold percentage and, after a divider, the reset countdown.
121// Light and dark come from prefers-color-scheme inside the SVG. Icons are Lucide paths (ISC).
122const CHIP_H = 20
123const CHIP_PAD = 9
124const ICON = 11
125const GAP = 7
126const LETTER = 0.6
127const BAR_H = 3
128const FONT = 11
129const BAR_W = 36
130const CHIP_THEMES: Record<string, { tint: string; accent: string; accentDark: string; icon: string }> = {
131  ctx: { tint: 'rgba(70,130,240,0.16)', accent: '#3d72d6', accentDark: '#7aa5f5', icon: 'layers' },
132  five_hour: { tint: 'rgba(76,175,80,0.16)', accent: '#3f8f43', accentDark: '#6cc070', icon: 'gauge' },
133  seven_day: { tint: 'rgba(124,92,230,0.16)', accent: '#6d4fd6', accentDark: '#a48cf5', icon: 'calendar' },
134}
135const ICONS: Record<string, string> = {
136  gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
137  calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
138  layers:
139    '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
140  history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
141}
142
143// Monospace advance: ASCII is 0.6em, anything wider (⚠, CJK) taken as a full em, plus letter-spacing
144function textWidth(text: string, size = FONT) {
145  return [...text].reduce((w, ch) => w + (ch.charCodeAt(0) < 128 ? 0.6 : 1) * size + LETTER, 0)
146}
147
148function esc(text: string) {
149  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
150}
151
152function icon(name: string, x: number, cls: string) {
153  const y = (CHIP_H - ICON) / 2
154  return `<g class="${cls}" transform="translate(${x} ${y}) scale(${ICON / 24})" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</g>`
155}
156
157function chipSvg(m: Meter) {
158  const theme = CHIP_THEMES[m.key] ?? CHIP_THEMES.ctx
159  const mid = CHIP_H / 2
160  const base = mid + FONT * 0.35
161  const parts: string[] = []
162  let x = CHIP_PAD
163
164  parts.push(icon(theme.icon, x, 'accent'))
165  x += ICON + 5
166  parts.push(`<text x="${x}" y="${base}" class="muted">${esc(m.label)}</text>`)
167  x += textWidth(m.label) + GAP
168
169  const barY = mid - BAR_H / 2
170  const fill = Math.round((clamp(m.used) / 100) * BAR_W)
171  parts.push(`<clipPath id="b"><rect x="${x}" y="${barY}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath>`)
172  parts.push(`<g clip-path="url(#b)"><rect x="${x}" y="${barY}" width="${BAR_W}" height="${BAR_H}" fill="${SVG_COLORS.track}"/>`)
173  if (fill > 0) parts.push(`<rect x="${x}" y="${barY}" width="${fill}" height="${BAR_H}" fill="${SVG_COLORS[m.tone]}"/>`)
174  parts.push('</g>')
175  if (m.elapsed !== null) {
176    const mx = x + Math.min(BAR_W - 2, Math.max(0, Math.round((m.elapsed / 100) * BAR_W) - 1))
177    parts.push(`<rect x="${mx}" y="${mid - 4}" width="1.5" height="8" rx="1" class="ink-fill"/>`)
178  }
179  x += BAR_W + GAP
180
181  // the percentage stays in ink while on pace, and takes the warning color once it isn't
182  const pctClass = m.tone === 'green' ? 'ink' : ''
183  const pctFill = m.tone === 'green' ? '' : ` fill="${SVG_COLORS[m.tone]}"`
184  parts.push(`<text x="${x}" y="${base}" font-weight="700" class="${pctClass}"${pctFill}>${esc(m.value)}</text>`)
185  x += textWidth(m.value)
186
187  if (m.reset) {
188    x += GAP
189    parts.push(`<rect x="${x}" y="${mid - 5}" width="1" height="10" class="divider"/>`)
190    x += 1 + GAP
191    parts.push(icon('history', x, 'accent'))
192    x += ICON + 5
193    parts.push(`<text x="${x}" y="${base}" class="accent-fill">${esc(m.reset)}</text>`)
194    x += textWidth(m.reset)
195  }
196  if (m.clock) {
197    x += 5
198    parts.push(`<text x="${x}" y="${base}" class="muted">${esc(m.clock)}</text>`)
199    x += textWidth(m.clock)
200  }
201  if (m.warning) {
202    x += GAP
203    parts.push(`<text x="${x}" y="${base}" font-weight="700" fill="${SVG_COLORS.red}">${esc(m.warning)}</text>`)
204    x += textWidth(m.warning)
205  }
206  const width = Math.ceil(x + CHIP_PAD)
207
208  const style =
209    `text{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:${FONT}px;letter-spacing:${LETTER}px}` +
210    `.ink{fill:#2b2b2b}.ink-fill{fill:#2b2b2b}.muted{fill:#6b6b6b}.divider{fill:rgba(0,0,0,0.15)}` +
211    `.accent{stroke:${theme.accent}}.accent-fill{fill:${theme.accent}}` +
212    `@media (prefers-color-scheme: dark){.ink,.ink-fill{fill:#ececec}.muted{fill:#a3a3a3}` +
213    `.divider{fill:rgba(255,255,255,0.18)}.accent{stroke:${theme.accentDark}}.accent-fill{fill:${theme.accentDark}}}`
214  return {
215    width,
216    source:
217      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${CHIP_H}" viewBox="0 0 ${width} ${CHIP_H}">` +
218      `<style>${style}</style><rect width="${width}" height="${CHIP_H}" rx="${CHIP_H / 2}" fill="${theme.tint}"/>` +
219      parts.join('') +
220      '</svg>',
221  }
222}
223
224// The terminal's bar as runs of cells: used in the tone color, the rest dim, the elapsed marker cyan
225function textBarRuns(used: number, elapsed: number | null) {
226  const filled = Math.round((clamp(used) / 100) * BAR_CELLS)
227  const marker = elapsed === null ? -1 : Math.min(BAR_CELLS - 1, Math.floor((elapsed / 100) * BAR_CELLS))
228  const runs: { kind: 'used' | 'rest' | 'marker'; text: string }[] = []
229  for (let i = 0; i < BAR_CELLS; i++) {
230    const kind = i === marker ? 'marker' : i < filled ? 'used' : 'rest'
231    const char = kind === 'marker' ? '┃' : kind === 'used' ? '█' : '░'
232    const last = runs[runs.length - 1]
233    if (last && last.kind === kind) last.text += char
234    else runs.push({ kind, text: char })
235  }
236  return runs
237}
238
239// Whether every meter fits on one line with its text bar
240function barsFit(meters: Meter[], columns: number) {
241  const width = meters.reduce(
242    (sum, m) => sum + m.label.length + 1 + BAR_CELLS + 1 + m.value.length + (m.reset ? m.reset.length + 3 : 0) + (m.clock ? m.clock.length + 1 : 0) + (m.warning ? m.warning.length + 2 : 0),
243    0,
244  )
245  return width + METER_GAP * (meters.length - 1) <= columns - 2
246}
247
248// Whether a render tree draws anything: text, or an element other than a bare Box or Text
249function hasContent(node: unknown): boolean {
250  if (node == null || node === false) return false
251  if (typeof node === 'string') return node.trim() !== ''
252  if (typeof node === 'number') return true
253  if (Array.isArray(node)) return node.some(hasContent)
254  const el = node as { type?: string; children?: unknown }
255  if (el.type !== 'Box' && el.type !== 'Text') return true
256  return hasContent(el.children)
257}
258
259export const register: Register = on => {
260  on('session.start', async ($, e, next) => {
261    const usage = await $.session.usage()
262    await update($, limits, () => usage.rateLimits)
263    await update($, contextPercent, () => usage.context.percent ?? null)
264    await recordSample($, usage.rateLimits)
265    ticker?.cancel()
266    ticker = $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
267    return next(e)
268  })
269
270  on('session.measure', async ($, e, next) => {
271    if (e.changed.includes('context')) {
272      await update($, contextPercent, () => e.context.percent ?? null)
273    }
274    if (e.changed.includes('rateLimits')) {
275      const before = await read($, limits)
276      for (const limit of e.rateLimits) {
277        const old = before.find(l => l.kind === limit.kind)?.percentUsed ?? 0
278        const crossed = WARN_AT.filter(t => old < t && limit.percentUsed >= t).pop()
279        if (crossed) {
280          $.ui.toast(`${WINDOWS[limit.kind]?.label ?? limit.kind} usage limit passed ${crossed}%`)
281        }
282      }
283      await update($, limits, () => e.rateLimits)
284      await recordSample($, e.rateLimits)
285    }
286    return next(e)
287  })
288
289  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290    const current: Limit[] = await read($, limits)
291    const ctx = await read($, contextPercent)
292    if (e.props.hasSurvey || (current.length === 0 && ctx === null)) {
293      return next(e)
294    }
295
296    const now = await $.clock.now()
297    const history: Sample[] = await read($, samples)
298    const meters: Meter[] = []
299    if (ctx !== null) {
300      meters.push({ key: 'ctx', label: 'ctx', used: ctx, elapsed: null, tone: toneOf(ctx, null), value: `${ctx}%`, reset: null, clock: null, warning: null })
301    }
302    for (const limit of current) meters.push(limitMeter(limit, now, history))
303
304    const elements = $.ui.resolve(e)
305    const { Box, Text } = elements
306    const Svg = 'Svg' in elements ? elements.Svg : null
307    const gauge = barsFit(meters, e.props.bodyColumns) ? 'text' : 'none'
308
309    const line = Svg ? (
310      <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
311        {meters.map(m => {
312          const chip = chipSvg(m)
313          return (
314            <Svg
315              key={`chip-${m.key}`}
316              source={chip.source}
317              alt={`${m.label} ${m.value}${m.reset ? `, resets in ${m.reset}` : ''}${m.elapsed === null ? '' : `, ${Math.round(m.elapsed)}% of the window gone`}${m.warning ? `, ${m.warning}` : ''}`}
318              width={chip.width}
319              height={CHIP_H}
320            />
321          )
322        })}
323      </Box>
324    ) : (
325      <Box flexDirection="row" columnGap={METER_GAP}>
326        {meters.map(m => (
327          <Box key={`meter-${m.key}`} flexDirection="row" columnGap={1} alignItems="center">
328            <Text>{m.label}</Text>
329            {gauge === 'text' ? (
330              <Text>
331                {textBarRuns(m.used, m.elapsed).map((run, i) =>
332                  run.kind === 'marker' ? (
333                    <Text key={i} color="cyan" bold>{run.text}</Text>
334                  ) : run.kind === 'used' ? (
335                    <Text key={i} color={m.tone}>{run.text}</Text>
336                  ) : (
337                    <Text key={i} dimColor>{run.text}</Text>
338                  ),
339                )}
340              </Text>
341            ) : null}
342            <Text color={m.tone}>{m.value}</Text>
343            {m.reset ? <Text color="cyan">↻ {m.reset}</Text> : null}
344            {m.clock ? <Text dimColor>{m.clock}</Text> : null}
345            {m.warning ? <Text color="red" bold>{m.warning}</Text> : null}
346          </Box>
347        ))}
348      </Box>
349    )
350
351    // keep what later mods draw in the band
352    const rest = await next(e)
353    // an empty tree from below would still take the row gap, leaving a blank row under the meters
354    if (!hasContent(rest)) return line
355    return (
356      <Box flexDirection="column" rowGap={e.surface === 'desktop' ? 1 : 0}>
357        {line}
358        {rest}
359      </Box>
360    )
361  })
362}
363
types/index.d.ts 9 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Sample = { t: number; p: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-limit': { limits: Limit[]; contextPercent: number | null; samples: Sample[] }
7  }
8}
9