SLOPSHOPPER

aiblueprint

AIBlueprint usage band above the prompt: session cost, 5-hour and weekly limits with pacing and reset time.

newbandtoaststatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aiblueprint
› 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 S ██░░░ 97k/200k $0.42 5h ██░░░ 31% +49.0% 59m ⚙ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
S ██░░░ 97k/200k $0.42 5h ██░░░ 31% +49.0% 59m ⚙
README

Claude Configuration

This repository contains Claude Code configuration files and automatically syncs with the upstream repository.

Automated Sync

This repository automatically syncs from Melvynx/aiblueprint-cli-premium once per day at midnight UTC.

How It Works

Manual Sync

To trigger a sync manually:

  1. Go to the Actions tab in this repository
  2. Select "Sync from Upstream Repository" workflow
  3. Click "Run workflow" button
  4. Select the main branch and click "Run workflow"

Monitoring

  • Check the Actions tab to view sync history and status
  • Each sync run shows which commits were synchronized
  • You'll receive notifications for failed sync attempts (configure in Settings → Notifications)

Notes

  • This repository should be treated as read-only to avoid merge conflicts
  • The upstream repository always wins in case of conflicts
  • Sync logs are available in the Actions tab for 90 days
Source 2 files
hooks/register.tsx 310 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Limit, Usage } from '../types'
5
6const SESSION_MS = 5 * 3600 * 1000
7const WEEK_MS = 7 * 24 * 3600 * 1000
8let limitsCache: Promise<string | undefined> | undefined
9// The status line script's cache under $HOME/.claude; undefined when HOME cannot be read.
10const limitsCachePath = ($: EngineInterface) =>
11  (limitsCache ??= $.process
12    .run(['printenv', 'HOME'])
13    .then(({ stdout }) => stdout.trim() ? `${stdout.trim()}/.claude/scripts/statusline/data/usage-limits-cache.json` : undefined)
14    .catch(() => undefined))
15
16const GREEN = '#57ab5a'
17const YELLOW = '#d4a72c'
18const ORANGE = '#ff8700'
19const RED = '#e5534b'
20const TRACK = '#8b8b8b'
21const BLUE = '#539bf5'
22const BAR_WIDTH = 36
23
24const usageAtom = atom({ plugin: 'aiblueprint', key: 'usage' } as const, null)
25const settingsOpenAtom = atom({ plugin: 'aiblueprint', key: 'settingsOpen' } as const, false)
26
27type Options = {
28  showSession: boolean
29  showFiveHour: boolean
30  showWeek: boolean
31  showBars: boolean
32  showPace: boolean
33  showReset: boolean
34  sessionLabel: string
35  fiveHourLabel: string
36  weekLabel: string
37  refreshSeconds: number
38}
39
40const DEFAULTS: Options = {
41  showSession: true,
42  showFiveHour: true,
43  showWeek: true,
44  showBars: true,
45  showPace: true,
46  showReset: true,
47  sessionLabel: 'S',
48  fiveHourLabel: '5h',
49  weekLabel: 'Week',
50  refreshSeconds: 30,
51}
52
53// The toggles the ⚙ row shows, in order.
54const TOGGLES = [
55  { field: 'showSession', label: 'Session' },
56  { field: 'showFiveHour', label: '5h' },
57  { field: 'showWeek', label: 'Week' },
58  { field: 'showBars', label: 'Barres' },
59  { field: 'showPace', label: 'Pace' },
60  { field: 'showReset', label: 'Reset' },
61] as const
62
63let isRunning = false
64let isPending = false
65
66function fillColor(percent: number) {
67  if (percent < 50) return GREEN
68  if (percent < 75) return YELLOW
69  if (percent < 90) return ORANGE
70  return RED
71}
72
73// Pace > 0: under the week's pace (budget left), < 0: burning faster than time passes.
74function paceColor(pace: number) {
75  if (pace >= 0) return GREEN
76  if (pace > -10) return YELLOW
77  return RED
78}
79
80function formatPace(pace: number) {
81  return `${pace >= 0 ? '+' : ''}${pace.toFixed(1)}%`
82}
83
84function formatCost(cost: number) {
85  return `$${cost < 10 ? cost.toFixed(2) : cost.toFixed(1)}`
86}
87
88// One unit only, the biggest that fits: 6j, 13h, 45m.
89function formatResetsIn(ms: number) {
90  const minutes = Math.max(0, Math.floor(ms / 60_000))
91  if (minutes < 60) return `${minutes}m`
92  const hours = Math.floor(minutes / 60)
93  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}j`
94}
95
96function barSvg(percent: number, color: string) {
97  const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((percent / 100) * BAR_WIDTH)))
98  return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_WIDTH}" height="4" viewBox="0 0 ${BAR_WIDTH} 4"><rect width="${BAR_WIDTH}" height="4" rx="2" fill="${TRACK}" fill-opacity="0.25"/><rect width="${filled}" height="4" rx="2" fill="${color}"/></svg>`
99}
100
101function barText(percent: number) {
102  const filled = Math.max(0, Math.min(5, Math.round((percent / 100) * 5)))
103  return `${'█'.repeat(filled)}${'░'.repeat(5 - filled)}`
104}
105
106// The same windows as the Plan usage limits panel: Session = the 5-hour limit, Week = the 7-day limit.
107function limitOf(usage: SessionUsage, kind: string, windowMs: number, now: number): Limit | undefined {
108  const limit = usage.rateLimits.find(item => item.kind === kind)
109  if (!limit) return undefined
110  const resetsInMs = limit.resetsAt ? new Date(limit.resetsAt).getTime() - now : undefined
111  const elapsedPercent = resetsInMs !== undefined ? ((windowMs - resetsInMs) / windowMs) * 100 : undefined
112  return {
113    percent: limit.percentUsed,
114    pace: elapsedPercent !== undefined ? elapsedPercent - limit.percentUsed : undefined,
115    resetsInMs,
116  }
117}
118
119function contextOf(usage: SessionUsage): Usage['context'] {
120  const { tokens, window, percent } = usage.context
121  const value = percent ?? (tokens !== undefined && window > 0 ? (tokens / window) * 100 : undefined)
122  return value === undefined ? undefined : { percent: value, tokens, window }
123}
124
125function formatTokens(count: number) {
126  return count >= 1000 ? `${Math.round(count / 1000)}k` : `${count}`
127}
128
129function formatContext(context: NonNullable<Usage['context']>) {
130  const tokens = context.tokens ?? Math.round((context.percent / 100) * context.window)
131  return `${formatTokens(tokens)}/${formatTokens(context.window)}`
132}
133
134// A pill with the token count written inside, like the context window bar. A darker fill and a
135// shadowed label keep the text readable over both the filled part and the empty track.
136const PILL_WIDTH = 84
137const PILL_HEIGHT = 16
138
139function contextSvg(context: NonNullable<Usage['context']>) {
140  const filled = Math.max(0, Math.min(PILL_WIDTH, Math.round((context.percent / 100) * PILL_WIDTH)))
141  return `<svg xmlns="http://www.w3.org/2000/svg" width="${PILL_WIDTH}" height="${PILL_HEIGHT}" viewBox="0 0 ${PILL_WIDTH} ${PILL_HEIGHT}"><defs><clipPath id="p"><rect width="${PILL_WIDTH}" height="${PILL_HEIGHT}" rx="${PILL_HEIGHT / 2}"/></clipPath></defs><g clip-path="url(#p)"><rect width="${PILL_WIDTH}" height="${PILL_HEIGHT}" fill="#3a3f47"/><rect width="${filled}" height="${PILL_HEIGHT}" fill="#2f6fd1"/></g><text x="${PILL_WIDTH / 2}" y="11.5" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif" font-size="10.5" font-weight="700" fill="#ffffff" stroke="#000000" stroke-opacity="0.35" stroke-width="2" paint-order="stroke">${formatContext(context)}</text></svg>`
142}
143
144function compute(usage: SessionUsage, now: number): Usage {
145  return {
146    cost: usage.cost?.usd ?? 0,
147    context: contextOf(usage),
148    session: limitOf(usage, 'five_hour', SESSION_MS, now),
149    week: limitOf(usage, 'seven_day', WEEK_MS, now),
150  }
151}
152
153// Keeps the terminal status line script on the engine's own readings, so it never calls the usage API.
154async function seedLimitsCache($: EngineInterface, usage: SessionUsage, now: number) {
155  const window = (kind: string) => {
156    const limit = usage.rateLimits.find(item => item.kind === kind)
157    return limit ? { utilization: limit.percentUsed, resets_at: limit.resetsAt ?? null } : null
158  }
159  const sevenDay = window('seven_day')
160  if (!sevenDay) return
161  const path = await limitsCachePath($)
162  if (!path) return
163  await $.fs
164    .write(path, JSON.stringify({ data: { five_hour: window('five_hour'), seven_day: sevenDay }, timestamp: now }, null, 2))
165    .catch(() => undefined)
166}
167
168async function refresh($: EngineInterface): Promise<void> {
169  if (isRunning) {
170    isPending = true
171    return
172  }
173  isRunning = true
174  try {
175    const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
176    const next = compute(usage, now)
177    await update($, usageAtom, () => next)
178    await seedLimitsCache($, usage, now)
179  } catch {
180    // keep the last band
181  } finally {
182    isRunning = false
183  }
184  if (isPending) {
185    isPending = false
186    await refresh($)
187  }
188}
189
190// Writes one userConfig field the way /config does; the module reloads with the new options.
191async function setOption($: EngineInterface, field: string, value: boolean) {
192  const rows = await $.config.list()
193  const row = rows.find(item => item.key.startsWith('aiblueprint') && item.key.endsWith(`.${field}`))
194  if (!row) {
195    $.ui.toast(`aiblueprint: réglage ${field} introuvable dans /config`)
196    return
197  }
198  const { deny } = await $.config.set({ key: row.key, value })
199  if (deny) $.ui.toast(`aiblueprint: ${deny}`)
200}
201
202async function toggleSettings($: EngineInterface) {
203  await update($, settingsOpenAtom, isOpen => !isOpen)
204}
205
206export const register: Register = (on, rawOptions) => {
207  const options: Options = { ...DEFAULTS, ...(rawOptions as Partial<Options>) }
208  const refreshMs = Math.max(5, options.refreshSeconds) * 1000
209
210  on('session.start', async ($, e, next) => {
211    const started = await next(e)
212    $.ui.status(undefined)
213    void refresh($)
214    $.clock.every(refreshMs, () => refresh($))
215    return started
216  })
217
218  on('session.measure', async ($, e, next) => {
219    const measured = await next(e)
220    void refresh($)
221    return measured
222  })
223
224  on('turn.complete', async ($, e, next) => {
225    const completed = await next(e)
226    void refresh($)
227    return completed
228  })
229
230  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231    const [usage, isSettingsOpen] = await Promise.all([read($, usageAtom), read($, settingsOpenAtom)])
232    if (e.props.hasSurvey || usage === null) return next(e)
233
234    const limits = [
235      { key: 'five-hour', label: options.fiveHourLabel, limit: options.showFiveHour ? usage.session : undefined },
236      { key: 'week', label: options.weekLabel, limit: options.showWeek ? usage.week : undefined },
237    ].filter((item): item is { key: string; label: string; limit: Limit } => item.limit !== undefined)
238
239    const { Box, Text, Button } = $.ui.resolve(e)
240    const settingsRow = isSettingsOpen && (
241      <Box key="settings" flexDirection="row" gap={1} flexWrap="wrap">
242        {TOGGLES.map(({ field, label }) => (
243          <Button
244            key={`toggle-${field}`}
245            label={`${options[field] ? '✓' : '·'} ${label}`}
246            dimColor={!options[field]}
247            onPress={() => setOption($, field, !options[field])}
248          />
249        ))}
250      </Box>
251    )
252    const gear = <Button key="gear" label="⚙" plain dimColor={!isSettingsOpen} onPress={() => toggleSettings($)} />
253
254    if (e.surface === 'terminal') {
255      return (
256        <Box flexDirection="column">
257          <Box flexDirection="row" gap={3}>
258            {options.showSession && (
259              <Text key="session">
260                <Text dimColor>{options.sessionLabel} </Text>
261                {usage.context !== undefined && <Text color={BLUE}>{options.showBars ? `${barText(usage.context.percent)} ` : ''}{formatContext(usage.context)} </Text>}
262                <Text bold>{formatCost(usage.cost)}</Text>
263              </Text>
264            )}
265            {limits.map(({ key, label, limit }) => (
266              <Text key={key}>
267                <Text dimColor>{label} </Text>
268                {options.showBars && <Text color={fillColor(limit.percent)}>{barText(limit.percent)} </Text>}
269                <Text bold>{Math.round(limit.percent)}%</Text>
270                {options.showPace && limit.pace !== undefined && <Text color={paceColor(limit.pace)}> {formatPace(limit.pace)}</Text>}
271                {options.showReset && limit.resetsInMs !== undefined && <Text dimColor> {formatResetsIn(limit.resetsInMs)}</Text>}
272              </Text>
273            ))}
274            {gear}
275          </Box>
276          {settingsRow}
277        </Box>
278      )
279    }
280
281    const { Svg } = $.ui.resolve(e)
282    return (
283      <Box flexDirection="column" gap={1}>
284        <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
285          {options.showSession && (
286            <Box key="session" flexDirection="row" alignItems="center" gap={1} borderStyle="round" borderDimColor paddingX={1}>
287              <Text dimColor>{options.sessionLabel}</Text>
288              {usage.context !== undefined && (
289                <Svg source={contextSvg(usage.context)} alt={`Contexte ${formatContext(usage.context)}`} width={PILL_WIDTH} height={PILL_HEIGHT} />
290              )}
291              <Text bold>{formatCost(usage.cost)}</Text>
292            </Box>
293          )}
294          {limits.map(({ key, label, limit }) => (
295            <Box key={key} flexDirection="row" alignItems="center" gap={1} borderStyle="round" borderDimColor paddingX={1}>
296              <Text dimColor>{label}</Text>
297              {options.showBars && <Svg source={barSvg(limit.percent, fillColor(limit.percent))} alt={`${label} ${Math.round(limit.percent)}%`} width={BAR_WIDTH} height={4} />}
298              <Text bold>{Math.round(limit.percent)}%</Text>
299              {options.showPace && limit.pace !== undefined && <Text bold color={paceColor(limit.pace)}>{formatPace(limit.pace)}</Text>}
300              {options.showReset && limit.resetsInMs !== undefined && <Text dimColor>{formatResetsIn(limit.resetsInMs)}</Text>}
301            </Box>
302          ))}
303          {gear}
304        </Box>
305        {settingsRow}
306      </Box>
307    )
308  })
309}
310
types/index.d.ts 15 lines
1export type Limit = { percent: number; pace?: number; resetsInMs?: number }
2
3export type Usage = {
4  cost: number
5  context?: { percent: number; tokens?: number; window: number }
6  session?: Limit
7  week?: Limit
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    aiblueprint: { usage: Usage | null; settingsOpen: boolean }
13  }
14}
15