SLOPSHOPPER

limits-band

Always-visible bar above the prompt showing remaining 5-hour and weekly limits.

newbandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-band
› 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 Hourly Left 69% (NaNm) · Context Left 51% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Hourly Left 69% (NaNm) · Context Left 51%
README

sourav-claude-mods

Claude Code mods by Sourav.

Mods

limits-band

A bar above the prompt that shows what is left of your limits:

limits-band showing Hourly Left, Weekly Left and Context Left above the prompt

  • Percent turns red below 20%.
  • The bracket shows time until that limit resets.
  • Hourly and weekly limits are shared between sessions, so every chat shows the freshest reading any session has seen, even before its own first reply.
  • First time after installing: send one message in any chat. Hourly and weekly limits only arrive with a reply, so until then only context shows. After that, every chat shows them.
  • When you switch to a chat that has no limits showing yet, they can take a few seconds to appear.
  • Needs a Claude subscription plan. On API billing, only context shows.

open-in-pycharm

Opens files in PyCharm from the chat:

  • A PyCharm: line under each of Claude's replies that links to files, one link per file. The reply itself is drawn by the app as usual; replies with no file links get nothing.
  • An "Open in PyCharm" button under each Read, Edit and Write row. An Edit opens at the changed line.
  • PyCharm starts through Explorer, so a running PyCharm takes the file instead of failing with "Start Failed".
  • Windows only. The PyCharm path is set in hooks/register.tsx.

Install

claude plugin marketplace add sourav-das-enosis/sourav-claude-mods
claude plugin install limits-band@sourav-claude-mods
claude plugin install open-in-pycharm@sourav-claude-mods

In the desktop app, list the mod folder in CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json instead (paths separated by ; on Windows).

Source 2 files
hooks/register.tsx 146 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { colorFor, formatShort, remainingPercent } from './format'
4
5type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6type Sample = { limits: Limit[]; contextUsed?: number }
7
8// Limits belong to the account, not the chat. A live reading is saved to the
9// store so a chat with no reply yet can show the last one any session saw.
10// Two readings of one limit: a later reset time is a newer window; within the
11// same window, use only goes up, so the higher figure is the fresher one.
12function fresher(a: Limit, b: Limit): Limit {
13  const ta = a.resetsAt ? Date.parse(a.resetsAt) : 0
14  const tb = b.resetsAt ? Date.parse(b.resetsAt) : 0
15
16  if (ta !== tb) return ta > tb ? a : b
17
18  return a.percentUsed >= b.percentUsed ? a : b
19}
20
21function mergeLimits(live: Limit[], saved: Limit[]): Limit[] {
22  const merged = new Map<string, Limit>()
23
24  for (const l of [...saved, ...live]) {
25    const current = merged.get(l.kind)
26    merged.set(l.kind, current ? fresher(current, l) : l)
27  }
28
29  return [...merged.values()]
30}
31
32// Limits belong to the account, not the chat: merge this chat's last reading
33// with the one any session saved, and save the result back.
34async function sample($: EngineInterface): Promise<Sample> {
35  const usage = await $.session.usage()
36  const contextUsed = usage.context.percent
37  // 'limitsShared', not 'limits': sessions still running the first version
38  // keep overwriting 'limits' with their own stale reading.
39  const stored = await $.store.get('limitsShared')
40  const saved = Array.isArray(stored) ? (stored as Limit[]) : []
41  const limits = mergeLimits(usage.rateLimits, saved)
42
43  if (limits.length > 0) await $.store.set('limitsShared', limits)
44
45  return { limits, contextUsed }
46}
47
48export const register: Register = on => {
49  let limits: Limit[] = []
50  let contextUsed: number | undefined
51  let isTicking = false
52
53  // A resumed chat has no rate-limit reading until its first reply, so an
54  // empty sample must not wipe the last known one.
55  const apply = (s: Sample) => {
56    limits = s.limits
57    if (s.contextUsed !== undefined) contextUsed = s.contextUsed
58  }
59
60  on('session.start', async ($, e, next) => {
61    if (!isTicking) {
62      isTicking = true
63      $.clock.every(10_000, async () => {
64        apply(await sample($))
65        $.ui.invalidate('ui.render')
66      })
67    }
68    apply(await sample($))
69    $.ui.invalidate('ui.render')
70
71    return next(e)
72  })
73
74  on('prompt.submit', async ($, e, next) => {
75    $.clock.after(4_000, async () => {
76      apply(await sample($))
77      $.ui.invalidate('ui.render')
78    })
79
80    return next(e)
81  })
82
83  on('turn.complete', async ($, e, next) => {
84    apply(await sample($))
85    $.ui.invalidate('ui.render')
86    $.clock.after(3_000, async () => {
87      apply(await sample($))
88      $.ui.invalidate('ui.render')
89    })
90
91    return next(e)
92  })
93
94  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
95    const { Box, Text } = $.ui.resolve(e)
96    const hourly = limits.find(l => l.kind === 'five_hour')
97    const weekly = limits.find(l => l.kind === 'seven_day')
98
99    if (!hourly && !weekly && contextUsed === undefined) {
100      return (
101        <Box justifyContent="center" width="100%">
102          <Text dimColor>Limits: waiting for first reply…</Text>
103        </Box>
104      )
105    }
106
107    const cell = (label: string, left: number, reset = '') => (
108      <Box key={label} flexDirection="row">
109        <Text bold>{label} </Text>
110        <Text bold color={colorFor(left)}>
111          {left}%
112        </Text>
113        <Text dimColor>{reset ? ` (${reset})` : ''}</Text>
114      </Box>
115    )
116
117    const cells = []
118
119    // A saved reading whose window has since reset means a fresh, full window.
120    const windowCell = (label: string, l: Limit) => {
121      const msLeft = l.resetsAt ? Date.parse(l.resetsAt) - Date.now() : undefined
122
123      if (msLeft !== undefined && msLeft <= 0) return cell(label, 100)
124
125      return cell(label, remainingPercent(l.percentUsed), msLeft === undefined ? '' : formatShort(msLeft))
126    }
127
128    if (hourly) cells.push(windowCell('Hourly Left', hourly))
129    if (weekly) cells.push(windowCell('Weekly Left', weekly))
130
131    if (contextUsed !== undefined) {
132      cells.push(cell('Context Left', remainingPercent(contextUsed)))
133    }
134
135    const parts = cells.flatMap((c, i) =>
136      i === 0 ? [c] : [<Text key={`dot${i}`} dimColor>{'   ·   '}</Text>, c],
137    )
138
139    return (
140      <Box flexDirection="row" justifyContent="center" width="100%">
141        {parts}
142      </Box>
143    )
144  })
145}
146
hooks/format.ts 18 lines
1export const formatShort = (ms: number): string => {
2  const totalMin = Math.max(0, Math.floor(ms / 60000))
3  const days = Math.floor(totalMin / 1440)
4  const hours = Math.floor((totalMin % 1440) / 60)
5  const mins = totalMin % 60
6
7  if (days > 0) return `${days}d ${hours}h`
8  if (hours > 0) return `${hours}h ${mins}m`
9
10  return `${mins}m`
11}
12
13export const remainingPercent = (percentUsed: number): number =>
14  Math.min(100, Math.max(0, Math.round(100 - percentUsed)))
15
16export const colorFor = (remaining: number): string | undefined =>
17  remaining < 20 ? 'error' : undefined
18