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.

A small open-source mod for Claude Code that keeps your usage in a side panel.
In the panel
! out in 40m).Layout
5h ██████░░░░ 29% left 7d ████░░░░░░ 22% left ctx 16% $1.42).compact: auto).◔ 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.
/plugin install credits-bar --marketplace jbug0x/credits-bar
Or, for development: claude --plugin-dir ./credits-bar.
hooks/register.tsx (all logic), types/index.d.ts (state contract).hooks/strings.ts)Contributions welcome. MIT licensed.
hooks/register.tsx 440 lines1import { 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}
440hooks/strings.ts 138 lines1// 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}
138hooks/usage.ts 353 lines1// 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}
353types/index.d.ts 27 lines1export 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