SLOPSHOPPER

credits-bar

A live usage panel for Claude Code: limit bars, pace warning, daily goal, context, tokens, cost per project, history and CSV export. English and Portuguese.

newpanebandcommandtoastprompt
v0.3.0MITupdated 2026-10-07jbug0x/credits-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · credits-bar
│ ┃ credits ✕ › fix the failing auth test and add an audit log call │ ┃ 5h 69% left │ ┃ █████████████████████░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ resetting ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Context 49% used ⎿ Added 2 lines, removed 1 line │ ┃ ███████████████░░░░░░░░░░░░░░░ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Tokens │ ┃ in 6k · out 1k · cache 93% ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Models (session) │ ┃ opus-5-5 $0.00 · 1k out ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Session $0.42 · last reply $0.00 › /credits-panel │ ┃ ⎿ credits-bar: Credits panel closed. │ ┃ 7 days │ ┃ spend ▁▁▁▁▁▁▁ · today $0.00 │ ┃ peak ▁▁▁▁▁▁▁ · today 0% │ [ ◔ 69% left ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ◔ 69% left ]
Pane · credits
5h 69% left █████████████████████░░░░░░░░░ resetting Context 49% used ███████████████░░░░░░░░░░░░░░░ Tokens in 6k · out 1k · cache 93% Models (session) opus-5-5 $0.00 · 1k out Session $0.42 · last reply $0.00 7 days spend ▁▁▁▁▁▁▁ · today $0.00 peak ▁▁▁▁▁▁▁ · today 0%
README

credits-bar

A small open-source mod for Claude Code that keeps your usage in a side panel.

In the panel

  • Limit bars (5-hour and 7-day) that drain as you spend, with a live "resets in" clock.
  • Pace warning when your burn rate would empty a window before it resets (! out in 40m).
  • Daily goal: set a USD goal and get its own bar plus a toast when it is reached.
  • Context window: fill and what is filling it. It refreshes every 30s, not only after replies.
  • Session tokens, prompt-cache hit rate and cost per model for the session.
  • Cost: session total, last reply and a mini chart of recent replies.
  • Spend per project (top folders over the last 7 days).
  • History: 7-day sparklines, growing into a 14-day bar chart once there is data and room.
  • Toasts at 80% / 95% (configurable), when a window goes on pace to run dry, and a summary when the session ends (total spent and limits used).

Layout

  • Wide window (144+ columns): the panel opens by itself at session start.
  • Narrow window: a one-line band above the prompt stands in (5h ██████░░░░ 29% left 7d ████░░░░░░ 22% left ctx 16% $1.42).
  • Short panel: switches to a compact view (compact: auto).
  • A small icon button (◔ 29% left) always sits above the prompt: it shows your tightest limit, and a click opens or closes the panel.
  • /credits-panel opens or closes the side panel (it opens at any width when you ask).
  • /credits-export writes the spend history to credits-history.csv in the current folder (date,project,usd,peak_percent).
  • /credits-bar shows or hides the one-line bar above the prompt (it can be shown even with the panel open).

Settings (userConfig, shown in the plugin config menu): alert thresholds, compact mode, colors (default, colorblind, mono), language (en, pt), daily goal, alert sound and which blocks to show.

Sound: the alert sound uses the engine's audio player, which exists on macOS only. On Windows and Linux the option does nothing.

Install

/plugin install credits-bar --marketplace jbug0x/credits-bar

Or, for development: claude --plugin-dir ./credits-bar.

Notes

  • Limit windows come from the rate-limit data Claude Code receives, so they only appear on a subscription. Otherwise you only see the session cost.
  • Nothing is sent anywhere: the mod only reads figures the engine already has.
  • Layout: hooks/register.tsx (all logic), types/index.d.ts (state contract).

Ideas / roadmap

  • Plan limits per model (needs the engine to expose them; today only per-model spend is shown)
  • Spend per project over longer ranges and per-session history
  • Cross-platform sound (the engine plays audio on macOS only)
  • More languages (add a table in hooks/strings.ts)
  • Weekly/monthly goals

Contributions welcome. MIT licensed.

Source 4 files
hooks/register.tsx 440 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { STRINGS } from './strings'
5import {
6  addTokens,
7  barChart,
8  cacheHitPercent,
9  drawBar,
10  formatTokens,
11  goalPercent,
12  historyCsv,
13  labelFor,
14  lastDays,
15  levelLeft,
16  levelUsed,
17  money,
18  newAlerts,
19  paceNote,
20  projectName,
21  projectTotals,
22  recordDay,
23  resetsIn,
24  resolveOptions,
25  shortModel,
26  sparkline,
27  tint,
28  toSnapshot
29} from './usage'
30
31const PANE = 'credits'
32const TICK_MS = 30_000
33const MAX_TURNS = 8
34const CHART_DAYS = 14
35
36const snapshot = atom({ plugin: 'credits-bar', key: 'snapshot' } as const, null)
37// 'auto': the band shows only while the panel is not seated; 'on': always; 'off': never.
38const bandMode = atom({ plugin: 'credits-bar', key: 'bandMode' } as const, 'auto')
39const turns = atom({ plugin: 'credits-bar', key: 'turns' } as const, [])
40const tokens = atom({ plugin: 'credits-bar', key: 'tokens' } as const, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
41const history = atom({ plugin: 'credits-bar', key: 'history' } as const, {})
42const tick = atom({ plugin: 'credits-bar', key: 'tick' } as const, 0)
43const models = atom({ plugin: 'credits-bar', key: 'models' } as const, {})
44
45async function isPaneUp($: EngineInterface): Promise<boolean> {
46  try {
47    return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
48  } catch {
49    return false
50  }
51}
52
53// Plays only where the platform has a player (macOS); elsewhere the engine plays nothing.
54async function playAlert($: EngineInterface): Promise<void> {
55  try {
56    await $.audio.play({ asset: 'sounds/alert.wav' })
57  } catch {
58    // no sound is never worth a failure
59  }
60}
61
62export const register: Register = (on, options) => {
63  const opts = resolveOptions(options as Record<string, unknown> | undefined)
64  const str = STRINGS[opts.language]
65
66  // Thresholds already toasted this process (keyed by limit, reset time and threshold).
67  let seen: ReadonlySet<string> = new Set()
68  let lastUsd: number | null = null
69  let pending = 0
70  let sessionSpent = 0
71  let project = ''
72  let goalDay = ''
73
74  on('session.start', async ($, e, next) => {
75    await update($, bandMode, () => 'auto')
76    project = projectName(await $.session.cwd())
77
78    await $.command.register({ name: 'credits-panel', description: str.cmdPanel })
79    await $.command.register({ name: 'credits-bar', description: str.cmdBar })
80    await $.command.register({ name: 'credits-export', description: str.cmdExport })
81
82    const stored = await $.store.get('history')
83    if (stored && typeof stored === 'object') {
84      await update($, history, () => stored as Record<string, { usd: number; peak: number }>)
85    }
86
87    // Between responses, refresh the limits and the context fill (a local read, free) and
88    // keep "resets in 48m" moving.
89    $.clock.every(TICK_MS, async () => {
90      try {
91        const value = toSnapshot(await $.session.usage({ breakdown: opts.showBreakdown ? 'summary' : undefined }))
92        await update($, snapshot, () => value)
93      } catch {
94        // keep the previous reading
95      }
96      await update($, tick, n => n + 1)
97    })
98
99    // Unasked, the pane seats from 144 columns; below that the one-line band stands in.
100    void $.ui.open({ id: PANE, title: 'Credits' })
101
102    return next(e)
103  })
104
105  // /credits-panel: open the side panel (at any width when asked), or close it.
106  on('command.run', { command: 'credits-panel' }, async $ => {
107    if (await isPaneUp($)) {
108      await $.ui.close({ id: PANE })
109
110      return { text: str.panelClosed }
111    }
112
113    const opened = await $.ui.open({ id: PANE, title: 'Credits' })
114
115    return { text: opened.isPlaced ? str.panelOpened : str.panelCannot }
116  })
117
118  // /credits-bar: show the one-line bar above the prompt, or hide it.
119  on('command.run', { command: 'credits-bar' }, async $ => {
120    const isShowing = (await read($, bandMode)) === 'on' || ((await read($, bandMode)) === 'auto' && !(await isPaneUp($)))
121    await update($, bandMode, () => (isShowing ? 'off' : 'on'))
122
123    return { text: isShowing ? str.barHidden : str.barShown }
124  })
125
126  // /credits-export: write the spend history to credits-history.csv in the current folder.
127  on('command.run', { command: 'credits-export' }, async $ => {
128    try {
129      const csv = historyCsv(await read($, history))
130      const path = `${await $.session.cwd()}/credits-history.csv`
131      await $.fs.write(path, csv.text)
132
133      return { text: str.exported(path, csv.rows) }
134    } catch (error) {
135      return { text: str.exportFailed(error instanceof Error ? error.message : 'unknown error') }
136    }
137  })
138
139  // After each response: refresh the numbers, record spend, and raise toasts.
140  on('session.measure', async ($, e, next) => {
141    const now = await $.clock.now()
142    const value = toSnapshot(e)
143
144    if (opts.showBreakdown) {
145      const detailed = await $.session.usage({ breakdown: 'summary' })
146      value.categories = toSnapshot(detailed).categories
147    }
148    await update($, snapshot, () => value)
149
150    const spent = lastUsd !== null && value.usd !== null && value.usd > lastUsd ? value.usd - lastUsd : 0
151    if (value.usd !== null) lastUsd = value.usd
152    pending += spent
153    sessionSpent += spent
154
155    const peak = Math.max(0, ...value.limits.map(l => l.percentUsed))
156    const days = recordDay(await read($, history), now, spent, peak, project || undefined)
157    await update($, history, () => days)
158    await $.store.set('history', days)
159
160    const alerts = newAlerts(value.limits, seen, opts, now)
161    seen = alerts.seen
162    for (const message of alerts.messages) $.ui.toast(message)
163    let isAlert = alerts.messages.length > 0
164
165    // The daily goal, once per day.
166    const today = new Date(now).toISOString().slice(0, 10)
167    const spentToday = days[today]?.usd ?? 0
168    if (opts.dailyGoal > 0 && spentToday >= opts.dailyGoal && goalDay !== today) {
169      goalDay = today
170      $.ui.toast(str.goalHit(money(spentToday), money(opts.dailyGoal)))
171      isAlert = true
172    }
173
174    if (isAlert && opts.sound) await playAlert($)
175
176    return next(e)
177  })
178
179  // The first reading, so the numbers are there from the first turn.
180  on('prompt.submit', async ($, e, next) => {
181    // A failed reading must never get in the way of the prompt.
182    try {
183      const value = toSnapshot(await $.session.usage({ breakdown: opts.showBreakdown ? 'summary' : undefined }))
184      await update($, snapshot, () => value)
185      if (lastUsd === null && value.usd !== null) lastUsd = value.usd
186    } catch {
187      // keep the previous reading
188    }
189
190    return next(e)
191  })
192
193  // A turn ended: file what it cost, which model ran it and what tokens it used.
194  on('turn.complete', async ($, e, next) => {
195    const cost = pending
196    pending = 0
197    await update($, turns, list => [...list, cost].slice(-MAX_TURNS))
198    if (e.usage) {
199      const usage = e.usage
200      await update($, tokens, t => addTokens(t, usage))
201      const name = shortModel(usage.model)
202      await update($, models, m => ({
203        ...m,
204        [name]: { usd: (m[name]?.usd ?? 0) + cost, output: (m[name]?.output ?? 0) + usage.output_tokens }
205      }))
206    }
207
208    return next(e)
209  })
210
211  // The session is ending: one toast with the total spent and how far the limits got.
212  on('session.end', async ($, e, next) => {
213    if (opts.sessionSummary) {
214      const snap = await read($, snapshot)
215      const parts = (snap?.limits ?? []).map(l => str.usedShort(labelFor(l.kind), Math.round(l.percentUsed))).join(' · ')
216      const spent = sessionSpent > 0 ? sessionSpent : (snap?.usd ?? 0)
217      if (spent > 0 || parts) $.ui.toast(str.summary(money(spent), parts))
218    }
219
220    return next(e)
221  })
222
223  // The side panel.
224  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225    const { Box, Text } = $.ui.resolve(e)
226    const snap = await read($, snapshot)
227
228    if (snap === null) {
229      return <Text dimColor>{str.noReading}</Text>
230    }
231
232    await read($, tick)
233    const now = Date.now()
234    const rows = e.viewport?.rows ?? 40
235    const isCompact = opts.compact === 'always' || (opts.compact === 'auto' && rows < 24)
236    const width = Math.max(8, Math.min(30, (e.viewport?.columns ?? 40) - 4))
237    const paint = (level: 'good' | 'warn' | 'bad') => tint(level, opts.palette)
238
239    const turnCosts = await read($, turns)
240    const lastCost = turnCosts.length > 0 ? turnCosts[turnCosts.length - 1] : undefined
241    const tok = await read($, tokens)
242    const hit = cacheHitPercent(tok)
243    const byModel = Object.entries(await read($, models)).sort((a, b) => b[1].usd - a[1].usd)
244    const hist = await read($, history)
245    const days = lastDays(hist, now, 7)
246    const today = days[days.length - 1]
247    const goal = today ? goalPercent(today.usd, opts.dailyGoal) : null
248    const dataDays = Object.keys(hist).length
249    const isTall = rows >= 30 && dataDays >= 4 && !isCompact
250    const longDays = lastDays(hist, now, CHART_DAYS)
251    const topProjects = projectTotals(hist, now, 7, 3)
252
253    return (
254      <Box flexDirection="column">
255        {snap.limits.length === 0 ? (
256          <Text dimColor>{str.noLimit}</Text>
257        ) : (
258          snap.limits.map(l => {
259            const left = Math.round((100 - l.percentUsed) * 10) / 10
260            const color = paint(levelLeft(left))
261            const pace = paceNote(l, now, opts.language)
262
263            return isCompact ? (
264              <Text key={l.kind} color={color}>
265                {labelFor(l.kind)} {drawBar(left, width - 10)} {left}%
266                <Text dimColor> {resetsIn(l.resetsAt, now, opts.language).replace(/^[^ ]+ [^ ]+ /, '')}</Text>
267                {pace ? <Text bold> !</Text> : null}
268              </Text>
269            ) : (
270              <Box key={l.kind} flexDirection="column">
271                <Text bold>
272                  {labelFor(l.kind)} <Text color={color}>{str.left(left)}</Text>
273                </Text>
274                <Text color={color}>{drawBar(left, width)}</Text>
275                <Text dimColor>{resetsIn(l.resetsAt, now, opts.language) || ' '}</Text>
276                {pace ? <Text color={paint('bad')} bold>{`! ${pace}`}</Text> : null}
277                <Text> </Text>
278              </Box>
279            )
280          })
281        )}
282
283        {goal !== null && today ? (
284          <Box flexDirection="column">
285            <Text bold>
286              {str.goal} <Text color={paint(levelUsed(Math.min(100, goal)))}>{goal}%</Text>
287            </Text>
288            {isCompact ? null : (
289              <Text color={paint(levelUsed(Math.min(100, goal)))}>{drawBar(100 - Math.min(100, goal), width)}</Text>
290            )}
291            <Text dimColor>{str.goalLine(money(today.usd), money(opts.dailyGoal))}</Text>
292            {isCompact ? null : <Text> </Text>}
293          </Box>
294        ) : null}
295
296        {snap.contextPercent !== null ? (
297          <Box flexDirection="column">
298            <Text bold>
299              {str.context} <Text color={paint(levelUsed(snap.contextPercent))}>{str.usedPercent(snap.contextPercent)}</Text>
300            </Text>
301            {isCompact ? null : (
302              <Text color={paint(levelUsed(snap.contextPercent))}>{drawBar(100 - snap.contextPercent, width)}</Text>
303            )}
304            {!isCompact && opts.showBreakdown
305              ? snap.categories.map(c => (
306                  <Text key={c.name} dimColor>
307                    {`  ${c.name} ${formatTokens(c.tokens)}`}
308                  </Text>
309                ))
310              : null}
311            {isCompact ? null : <Text> </Text>}
312          </Box>
313        ) : null}
314
315        {opts.showTokens && !isCompact && tok.input + tok.output + tok.cacheRead > 0 ? (
316          <Box flexDirection="column">
317            <Text bold>{str.tokens}</Text>
318            <Text dimColor>
319              {`  ${str.tokensLine(formatTokens(tok.input + tok.cacheWrite), formatTokens(tok.output))}`}
320              {hit !== null ? ` · ${str.cacheHit(hit)}` : ''}
321            </Text>
322            {byModel.length > 0 ? (
323              <Box flexDirection="column">
324                <Text bold>{str.models}</Text>
325                {byModel.slice(0, 3).map(([name, m]) => (
326                  <Text key={name} dimColor>{`  ${name} ${money(m.usd)} · ${formatTokens(m.output)} out`}</Text>
327                ))}
328              </Box>
329            ) : null}
330            <Text> </Text>
331          </Box>
332        ) : null}
333
334        {snap.usd !== null ? (
335          <Text dimColor>
336            {`${str.session} ${money(snap.usd)}`}
337            {opts.showTurns && lastCost !== undefined ? ` · ${str.lastReply} ${money(lastCost)}` : ''}
338          </Text>
339        ) : null}
340        {opts.showTurns && !isCompact && turnCosts.length > 1 ? (
341          <Text dimColor>{`${str.replies} ${sparkline(turnCosts)}`}</Text>
342        ) : null}
343
344        {opts.showProjects && !isCompact && topProjects.length > 0 ? (
345          <Box flexDirection="column">
346            <Text> </Text>
347            <Text bold>{str.projects}</Text>
348            {topProjects.map(([name, usd]) => (
349              <Text key={name} dimColor>{`  ${name} ${money(usd)}`}</Text>
350            ))}
351          </Box>
352        ) : null}
353
354        {opts.showHistory && !isCompact && today ? (
355          <Box flexDirection="column">
356            <Text> </Text>
357            <Text bold>{str.days(isTall ? CHART_DAYS : 7)}</Text>
358            {isTall ? (
359              <Box flexDirection="column">
360                {barChart(longDays.map(d => d.usd), 4).map((line, i) => (
361                  <Text key={i} dimColor>{`  ${line}`}</Text>
362                ))}
363                <Text dimColor>{`  ${str.spend} · ${str.today} ${money(today.usd)}`}</Text>
364              </Box>
365            ) : (
366              <Box flexDirection="column">
367                <Text dimColor>{`  ${str.spend} ${sparkline(days.map(d => d.usd))} · ${str.today} ${money(today.usd)}`}</Text>
368                <Text dimColor>{`  ${str.peak}  ${sparkline(days.map(d => d.peak))} · ${str.today} ${Math.round(today.peak)}%`}</Text>
369              </Box>
370            )}
371          </Box>
372        ) : null}
373      </Box>
374    )
375  })
376
377  // The one-line bar: the fallback while the panel is not seated, or always with /credits-bar.
378  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
379    if (e.props.hasSurvey) {
380      return next(e)
381    }
382
383    const snap = await read($, snapshot)
384    const mode = await read($, bandMode)
385    const isWanted = mode === 'on' || (mode === 'auto' && !(await isPaneUp($)))
386
387    await read($, tick)
388    const { Box, Text, Button } = $.ui.resolve(e)
389    const now = Date.now()
390    const paint = (level: 'good' | 'warn' | 'bad') => tint(level, opts.palette)
391
392    // The icon button: always there, shows the tightest limit, click toggles the side panel.
393    const tightest = snap && snap.limits.length > 0 ? Math.min(...snap.limits.map(l => 100 - l.percentUsed)) : null
394    const chip = (
395      <Button
396        key="credits-chip"
397        label={tightest === null ? str.chipEmpty : str.chipLeft(Math.round(tightest))}
398        onPress={async () => {
399          if (await isPaneUp($)) await $.ui.close({ id: PANE })
400          else await $.ui.open({ id: PANE, title: 'Credits' })
401        }}
402      />
403    )
404
405    if (snap === null || !isWanted) {
406      return <Box justifyContent="flex-end">{chip}</Box>
407    }
408
409    // The bar's readings on the left, the icon button pushed to the right edge.
410    return (
411      <Box justifyContent="space-between">
412        <Box>
413          {snap.limits.length === 0 ? (
414            <Text dimColor>{str.noLimitBar}</Text>
415          ) : (
416            snap.limits.map(l => {
417              const left = Math.round((100 - l.percentUsed) * 10) / 10
418              const pace = paceNote(l, now, opts.language)
419
420              return (
421                <Text key={l.kind} color={paint(levelLeft(left))}>
422                  {labelFor(l.kind)} {drawBar(left, 10)} {str.left(left)}
423                  {pace ? <Text bold>{` (${pace})`}</Text> : null}
424                  {'  '}
425                </Text>
426              )
427            })
428          )}
429          {snap.contextPercent !== null ? (
430            <Text color={paint(levelUsed(snap.contextPercent))}>ctx {snap.contextPercent}% </Text>
431          ) : null}
432          {snap.usd !== null ? <Text dimColor>{money(snap.usd)} </Text> : null}
433          <Button key="hide" label={str.hide} onPress={() => update($, bandMode, () => 'off')} />
434        </Box>
435        {chip}
436      </Box>
437    )
438  })
439}
440
hooks/strings.ts 138 lines
1// Every label the mod draws or toasts, in each language. Add a language by adding a table.
2export type Lang = 'en' | 'pt'
3
4type Table = {
5  left: (percent: number) => string
6  resetsIn: (duration: string) => string
7  resetting: string
8  outIn: (duration: string) => string
9  noReading: string
10  noLimit: string
11  noLimitBar: string
12  context: string
13  usedPercent: (percent: number) => string
14  tokens: string
15  tokensLine: (input: string, output: string) => string
16  cacheHit: (percent: number) => string
17  session: string
18  lastReply: string
19  replies: string
20  days: (n: number) => string
21  spend: string
22  peak: string
23  today: string
24  projects: string
25  models: string
26  goal: string
27  goalLine: (spent: string, goal: string) => string
28  hide: string
29  chipEmpty: string
30  chipLeft: (percent: number) => string
31  // toasts
32  limitUsed: (label: string, percent: number) => string
33  paceWarn: (label: string, pace: string) => string
34  goalHit: (spent: string, goal: string) => string
35  summary: (spent: string, parts: string) => string
36  usedShort: (label: string, percent: number) => string
37  // commands
38  panelClosed: string
39  panelOpened: string
40  panelCannot: string
41  barHidden: string
42  barShown: string
43  exported: (path: string, rows: number) => string
44  exportFailed: (reason: string) => string
45  cmdPanel: string
46  cmdBar: string
47  cmdExport: string
48}
49
50export const STRINGS: Record<Lang, Table> = {
51  en: {
52    left: p => `${p}% left`,
53    resetsIn: d => `resets in ${d}`,
54    resetting: 'resetting',
55    outIn: d => `out in ${d}`,
56    noReading: 'No usage reading yet. Send a prompt.',
57    noLimit: 'No usage limit reported (not on a subscription?).',
58    noLimitBar: 'Credits: no usage limit reported yet ',
59    context: 'Context',
60    usedPercent: p => `${p}% used`,
61    tokens: 'Tokens',
62    tokensLine: (i, o) => `in ${i} · out ${o}`,
63    cacheHit: p => `cache ${p}%`,
64    session: 'Session',
65    lastReply: 'last reply',
66    replies: 'Replies',
67    days: n => `${n} days`,
68    spend: 'spend',
69    peak: 'peak',
70    today: 'today',
71    projects: 'Projects (7d)',
72    models: 'Models (session)',
73    goal: 'Daily goal',
74    goalLine: (s, g) => `${s} of ${g}`,
75    hide: 'Hide (/credits-bar to restore)',
76    chipEmpty: '◔ credits',
77    chipLeft: p => `◔ ${p}% left`,
78    limitUsed: (l, p) => `${l} limit ${p}% used`,
79    paceWarn: (l, p) => `${l} window ${p}, before it resets`,
80    goalHit: (s, g) => `Daily goal reached: ${s} of ${g}`,
81    summary: (s, parts) => `Session ended: ${s} spent${parts ? ` · ${parts}` : ''}`,
82    usedShort: (l, p) => `${l} ${p}% used`,
83    panelClosed: 'Credits panel closed.',
84    panelOpened: 'Credits panel opened.',
85    panelCannot: 'The panel could not be placed here.',
86    barHidden: 'Credits bar hidden. Run /credits-bar to show it again.',
87    barShown: 'Credits bar shown.',
88    exported: (p, n) => `History exported: ${n} rows in ${p}`,
89    exportFailed: r => `Could not export the history: ${r}`,
90    cmdPanel: 'Open or close the usage side panel',
91    cmdBar: 'Show or hide the one-line usage bar above the prompt',
92    cmdExport: 'Export the spend history to credits-history.csv in the current folder'
93  },
94  pt: {
95    left: p => `${p}% restante`,
96    resetsIn: d => `reseta em ${d}`,
97    resetting: 'resetando',
98    outIn: d => `acaba em ${d}`,
99    noReading: 'Ainda sem leitura de uso. Envie um prompt.',
100    noLimit: 'Nenhum limite de uso informado (sem assinatura?).',
101    noLimitBar: 'Créditos: nenhum limite informado ainda ',
102    context: 'Contexto',
103    usedPercent: p => `${p}% usado`,
104    tokens: 'Tokens',
105    tokensLine: (i, o) => `entrada ${i} · saída ${o}`,
106    cacheHit: p => `cache ${p}%`,
107    session: 'Sessão',
108    lastReply: 'última resposta',
109    replies: 'Respostas',
110    days: n => `${n} dias`,
111    spend: 'gasto',
112    peak: 'pico',
113    today: 'hoje',
114    projects: 'Projetos (7d)',
115    models: 'Modelos (sessão)',
116    goal: 'Meta diária',
117    goalLine: (s, g) => `${s} de ${g}`,
118    hide: 'Ocultar (/credits-bar para voltar)',
119    chipEmpty: '◔ créditos',
120    chipLeft: p => `◔ ${p}% restante`,
121    limitUsed: (l, p) => `Limite de ${l}: ${p}% usado`,
122    paceWarn: (l, p) => `Janela de ${l} ${p}, antes de resetar`,
123    goalHit: (s, g) => `Meta diária atingida: ${s} de ${g}`,
124    summary: (s, parts) => `Sessão encerrada: ${s} gastos${parts ? ` · ${parts}` : ''}`,
125    usedShort: (l, p) => `${l} ${p}% usado`,
126    panelClosed: 'Painel de créditos fechado.',
127    panelOpened: 'Painel de créditos aberto.',
128    panelCannot: 'Não foi possível posicionar o painel aqui.',
129    barHidden: 'Barra de créditos oculta. Use /credits-bar para mostrar de novo.',
130    barShown: 'Barra de créditos exibida.',
131    exported: (p, n) => `Histórico exportado: ${n} linhas em ${p}`,
132    exportFailed: r => `Não foi possível exportar o histórico: ${r}`,
133    cmdPanel: 'Abrir ou fechar o painel lateral de uso',
134    cmdBar: 'Mostrar ou ocultar a barra de uma linha acima do prompt',
135    cmdExport: 'Exportar o histórico de gastos para credits-history.csv na pasta atual'
136  }
137}
138
hooks/usage.ts 353 lines
1// Pure helpers (no engine calls) so they can be unit-tested.
2import type { Category, DayRecord, History, Limit, Snapshot, TokenTotals } from '../types'
3import { STRINGS } from './strings'
4import type { Lang } from './strings'
5
6export const WINDOW_MS: Record<string, number> = {
7  five_hour: 5 * 60 * 60 * 1000,
8  seven_day: 7 * 24 * 60 * 60 * 1000
9}
10
11export type Options = {
12  alertLow: number
13  alertHigh: number
14  showHistory: boolean
15  showBreakdown: boolean
16  showTokens: boolean
17  showTurns: boolean
18  compact: 'auto' | 'always' | 'never'
19  palette: 'default' | 'colorblind' | 'mono'
20  language: Lang
21  dailyGoal: number
22  sessionSummary: boolean
23  showProjects: boolean
24  sound: boolean
25}
26
27export const DEFAULT_OPTIONS: Options = {
28  alertLow: 80,
29  alertHigh: 95,
30  showHistory: true,
31  showBreakdown: true,
32  showTokens: true,
33  showTurns: true,
34  compact: 'auto',
35  palette: 'default',
36  language: 'en',
37  dailyGoal: 0,
38  sessionSummary: true,
39  showProjects: true,
40  sound: false
41}
42
43// `userConfig` values arrive loosely typed (numbers may arrive as strings): clamp and default each.
44export function resolveOptions(raw: Record<string, unknown> | undefined): Options {
45  const o = raw ?? {}
46  const num = (v: unknown, d: number) =>
47    v !== '' && v !== null && v !== undefined && Number.isFinite(Number(v)) ? Number(v) : d
48  const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
49  const pick = <T extends string>(v: unknown, allowed: readonly T[], d: T): T =>
50    allowed.includes(v as T) ? (v as T) : d
51
52  const low = Math.min(100, Math.max(1, num(o.alertLow, DEFAULT_OPTIONS.alertLow)))
53  const high = Math.min(100, Math.max(low, num(o.alertHigh, DEFAULT_OPTIONS.alertHigh)))
54
55  return {
56    alertLow: low,
57    alertHigh: high,
58    showHistory: bool(o.showHistory, true),
59    showBreakdown: bool(o.showBreakdown, true),
60    showTokens: bool(o.showTokens, true),
61    showTurns: bool(o.showTurns, true),
62    compact: pick(o.compact, ['auto', 'always', 'never'] as const, 'auto'),
63    palette: pick(o.palette, ['default', 'colorblind', 'mono'] as const, 'default'),
64    language: pick(o.language, ['en', 'pt'] as const, 'en'),
65    dailyGoal: Math.max(0, num(o.dailyGoal, 0)),
66    sessionSummary: bool(o.sessionSummary, true),
67    showProjects: bool(o.showProjects, true),
68    sound: bool(o.sound, false)
69  }
70}
71
72type RawLimit = { kind: string; percentUsed: number; resetsAt?: string }
73type RawCategory = { name: string; tokens: number; kind: string }
74type RawUsage = {
75  rateLimits: RawLimit[]
76  cost?: { usd: number }
77  context: { percent?: number; breakdown?: { categories: RawCategory[] } }
78}
79
80export function toSnapshot(u: RawUsage): Snapshot {
81  return {
82    limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
83    usd: u.cost?.usd ?? null,
84    contextPercent: u.context.percent ?? null,
85    categories: topCategories(u.context.breakdown?.categories ?? [])
86  }
87}
88
89// What is filling the window: the biggest `used` rows, largest first.
90export function topCategories(rows: RawCategory[], n = 4): Category[] {
91  return rows
92    .filter(r => r.kind === 'used' && r.tokens > 0)
93    .sort((a, b) => b.tokens - a.tokens)
94    .slice(0, n)
95    .map(r => ({ name: r.name, tokens: r.tokens }))
96}
97
98export function formatTokens(n: number): string {
99  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
100  if (n >= 1000) return `${Math.round(n / 1000)}k`
101
102  return String(n)
103}
104
105// "Out in 40m": if the current burn rate holds, when does this window run dry?
106// Returns null when there is not enough signal or the window will last until reset.
107export function paceNote(limit: Limit, now: number, lang: Lang = 'en'): string | null {
108  const windowMs = WINDOW_MS[limit.kind]
109  if (!windowMs || !limit.resetsAt || limit.percentUsed < 10) return null
110
111  const remainingMs = Date.parse(limit.resetsAt) - now
112  const elapsedMs = windowMs - remainingMs
113  if (!(remainingMs > 0) || elapsedMs < windowMs * 0.05) return null
114
115  const msToEmpty = (elapsedMs * (100 - limit.percentUsed)) / limit.percentUsed
116  if (msToEmpty >= remainingMs) return null
117
118  return STRINGS[lang].outIn(formatDuration(msToEmpty))
119}
120
121export function formatDuration(ms: number): string {
122  const minutes = Math.max(1, Math.round(ms / 60000))
123  if (minutes < 60) return `${minutes}m`
124  if (minutes < 24 * 60) return `${Math.round(minutes / 60)}h`
125
126  return `${Math.round(minutes / (24 * 60))}d`
127}
128
129export function resetsIn(iso: string | undefined, now: number, lang: Lang = 'en'): string {
130  if (!iso) return ''
131  const ms = Date.parse(iso) - now
132  if (!(ms > 0)) return STRINGS[lang].resetting
133
134  return STRINGS[lang].resetsIn(formatDuration(ms))
135}
136
137export function labelFor(kind: string): string {
138  return kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'Spend' : kind
139}
140
141// Toasts: a limit passing `alertLow` or `alertHigh`, and a window newly on pace to run dry.
142// Each fires once per window (keys include the reset time).
143export function newAlerts(
144  limits: Limit[],
145  seen: ReadonlySet<string>,
146  opts: Pick<Options, 'alertLow' | 'alertHigh'> & { language?: Lang } = DEFAULT_OPTIONS,
147  now = 0
148): { messages: string[]; seen: Set<string> } {
149  const str = STRINGS[opts.language ?? 'en']
150  const next = new Set(seen)
151  const messages: string[] = []
152
153  for (const l of limits) {
154    const label = labelFor(l.kind)
155
156    for (const t of [opts.alertLow, opts.alertHigh]) {
157      const key = `${l.kind}:${l.resetsAt ?? ''}:${t}`
158      if (l.percentUsed >= t && !next.has(key)) {
159        next.add(key)
160        messages.push(str.limitUsed(label, Math.floor(l.percentUsed)))
161      }
162    }
163
164    const pace = now ? paceNote(l, now, opts.language ?? 'en') : null
165    const paceKey = `pace:${l.kind}:${l.resetsAt ?? ''}`
166    if (pace && !next.has(paceKey)) {
167      next.add(paceKey)
168      messages.push(str.paceWarn(label, pace))
169    }
170  }
171
172  // Crossing both thresholds in one jump yields the same text twice; show it once.
173  return { messages: [...new Set(messages)], seen: next }
174}
175
176// --- history (kept across sessions in $.store) ---
177
178export const HISTORY_DAYS = 14
179
180export function dayKey(ms: number): string {
181  return new Date(ms).toISOString().slice(0, 10)
182}
183
184// Adds `usd` spent and the highest limit percentage seen to today's record, pruning old days.
185export function recordDay(history: History, ms: number, usd: number, peak: number, project?: string): History {
186  const key = dayKey(ms)
187  const prev: DayRecord = history[key] ?? { usd: 0, peak: 0 }
188  const add = Math.max(0, usd)
189  const projects = { ...(prev.projects ?? {}) }
190  if (project && add > 0) projects[project] = (projects[project] ?? 0) + add
191  const next: History = {
192    ...history,
193    [key]: {
194      usd: prev.usd + add,
195      peak: Math.max(prev.peak, peak),
196      ...(Object.keys(projects).length > 0 ? { projects } : {})
197    }
198  }
199  const keep = Object.keys(next).sort().slice(-HISTORY_DAYS)
200
201  return Object.fromEntries(keep.map(k => [k, next[k] as DayRecord]))
202}
203
204// The last `days` days, oldest first, zero-filled.
205export function lastDays(history: History, ms: number, days = 7): { key: string; usd: number; peak: number }[] {
206  const out = []
207  for (let i = days - 1; i >= 0; i--) {
208    const key = dayKey(ms - i * 24 * 3600 * 1000)
209    const r = history[key]
210    out.push({ key, usd: r?.usd ?? 0, peak: r?.peak ?? 0 })
211  }
212
213  return out
214}
215
216const BLOCKS = '▁▂▃▄▅▆▇█'
217
218export function sparkline(values: number[]): string {
219  const max = Math.max(...values, 0)
220  if (max <= 0) return BLOCKS.charAt(0).repeat(values.length)
221
222  return values
223    .map(v => (v <= 0 ? BLOCKS.charAt(0) : BLOCKS.charAt(Math.min(7, Math.ceil((v / max) * 7)))))
224    .join('')
225}
226
227// --- tokens ---
228
229export function addTokens(
230  t: TokenTotals,
231  u: {
232    input_tokens: number
233    output_tokens: number
234    cache_read_input_tokens: number
235    cache_creation_input_tokens: number
236  }
237): TokenTotals {
238  return {
239    input: t.input + u.input_tokens,
240    output: t.output + u.output_tokens,
241    cacheRead: t.cacheRead + u.cache_read_input_tokens,
242    cacheWrite: t.cacheWrite + u.cache_creation_input_tokens
243  }
244}
245
246// Share of input served from the prompt cache, 0 to 100; null with no input yet.
247export function cacheHitPercent(t: TokenTotals): number | null {
248  const total = t.input + t.cacheRead + t.cacheWrite
249  if (total <= 0) return null
250
251  return Math.round((t.cacheRead / total) * 100)
252}
253
254// --- palette ---
255
256export type Level = 'good' | 'warn' | 'bad'
257export type Tint = 'green' | 'yellow' | 'red' | 'blue' | 'magenta' | undefined
258
259export function tint(level: Level, palette: Options['palette']): Tint {
260  if (palette === 'mono') return undefined
261  if (palette === 'colorblind') return level === 'good' ? 'blue' : level === 'warn' ? 'yellow' : 'magenta'
262
263  return level === 'good' ? 'green' : level === 'warn' ? 'yellow' : 'red'
264}
265
266export const levelLeft = (percentLeft: number): Level => (percentLeft > 50 ? 'good' : percentLeft > 20 ? 'warn' : 'bad')
267export const levelUsed = (percentUsed: number): Level => (percentUsed < 50 ? 'good' : percentUsed < 80 ? 'warn' : 'bad')
268
269export function drawBar(percentLeft: number, width: number): string {
270  const filled = Math.round((Math.max(0, Math.min(100, percentLeft)) / 100) * width)
271
272  return '█'.repeat(filled) + '░'.repeat(width - filled)
273}
274
275// --- projects, models, goal, chart, csv ---
276
277// The folder's own name: "C:\code\my-app" and "/home/me/my-app" both give "my-app".
278export function projectName(cwd: string): string {
279  const parts = cwd.split(/[\\/]+/).filter(Boolean)
280
281  return parts[parts.length - 1] ?? cwd
282}
283
284// Spend per project over the last `days` days, biggest first.
285export function projectTotals(history: History, ms: number, days = 7, n = 4): [string, number][] {
286  const totals: Record<string, number> = {}
287  for (const d of lastDays(history, ms, days)) {
288    for (const [name, usd] of Object.entries(history[d.key]?.projects ?? {})) totals[name] = (totals[name] ?? 0) + usd
289  }
290
291  return Object.entries(totals)
292    .sort((a, b) => b[1] - a[1])
293    .slice(0, n)
294}
295
296// "claude-opus-4-5-20251001" -> "opus-4-5"
297export function shortModel(model: string): string {
298  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
299}
300
301export function money(usd: number): string {
302  return `$${usd.toFixed(2)}`
303}
304
305// How far along the daily goal is, 0 to 100+; null when no goal is set.
306export function goalPercent(spent: number, goal: number): number | null {
307  return goal > 0 ? Math.round((spent / goal) * 100) : null
308}
309
310// A vertical bar chart `height` rows tall, one column pair per value; rows run top to bottom.
311export function barChart(values: number[], height: number): string[] {
312  const max = Math.max(...values, 0)
313  const rows: string[] = []
314
315  for (let r = height - 1; r >= 0; r--) {
316    let line = ''
317    for (const v of values) {
318      const units = max > 0 ? Math.round((v / max) * height * 8) : 0
319      const fill = Math.max(0, Math.min(8, units - r * 8))
320      const cell = fill === 0 ? ' ' : BLOCKS.charAt(fill - 1)
321      line += cell + cell
322    }
323    rows.push(line.replace(/\s+$/, ''))
324  }
325
326  return rows
327}
328
329// The spend history as CSV: one row per day and project (a "-" project when none was recorded).
330export function historyCsv(history: History): { text: string; rows: number } {
331  const lines = ['date,project,usd,peak_percent']
332  let rows = 0
333
334  for (const date of Object.keys(history).sort()) {
335    const rec = history[date] as DayRecord
336    const entries = Object.entries(rec.projects ?? {})
337    const named = entries.reduce((sum, [, v]) => sum + v, 0)
338    const csvName = (n: string) => (/[",\n]/.test(n) ? `"${n.replace(/"/g, '""')}"` : n)
339
340    for (const [name, usd] of entries) {
341      lines.push(`${date},${csvName(name)},${usd.toFixed(4)},${Math.round(rec.peak)}`)
342      rows++
343    }
344    const rest = rec.usd - named
345    if (entries.length === 0 || rest > 0.00005) {
346      lines.push(`${date},-,${Math.max(0, rest).toFixed(4)},${Math.round(rec.peak)}`)
347      rows++
348    }
349  }
350
351  return { text: lines.join('\n') + '\n', rows }
352}
353
types/index.d.ts 27 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Category = { name: string; tokens: number }
3export type Snapshot = {
4  limits: Limit[]
5  usd: number | null
6  contextPercent: number | null
7  categories: Category[]
8}
9export type TokenTotals = { input: number; output: number; cacheRead: number; cacheWrite: number }
10export type DayRecord = { usd: number; peak: number; projects?: Record<string, number> }
11export type ModelTotals = Record<string, { usd: number; output: number }>
12export type History = Record<string, DayRecord>
13
14declare module 'claude-code' {
15  interface PluginState {
16    'credits-bar': {
17      snapshot: Snapshot | null
18      bandMode: 'auto' | 'on' | 'off'
19      turns: number[]
20      tokens: TokenTotals
21      history: History
22      tick: number
23      models: ModelTotals
24    }
25  }
26}
27