Usage tracking for Claude Code: 5-hour and weekly quota left, context, session cost, run-out forecast, saving tips, 7-day history and shared themes for every…

Mods for Claude Code: small plugins that put on screen what you would otherwise keep asking Claude. One folder per mod; install only the ones you want. Every mod speaks English or French.
| Mod | What it shows | Commands |
|---|---|---|
suivi-conso — usage | 5-hour and weekly quota left, context in tokens, session cost (API equivalent), when the quota runs out at the current pace, what cost the most, tips to save usage, 7-day history. Also provides the shared themes | /conso, /conso band, /theme-mods <theme> |
ou-on-en-est — where things stand | For each repo: branch, uncommitted, unpushed, to pull, PR and CI. On your server: the deployed commit against main, changes made in place, whether your pages answer. Notes per repo | /ou-on-en-est, /ou-on-en-est note <repo> <text> |
garde-prod — production guard | Every command that touches production or is risky (push --force, secrets, rm -rf…), a fresh database backup before a production write (can refuse without one), and refused commands kept ready to run yourself with ! | /prod |
apk-fraicheur — APK freshness | Is the Android APK up to date with the code? Build in progress and its duration, failure or out-of-memory flagged at once, size, copy to another folder | /apk, /apk copy |
A band above the prompt (terminal and desktop) gives the essentials; each command opens a pane with the details, which also works on mobile.
5 h ███████░░░ 62% left │ 7 d 42% │ ctx 342 k / 1 M │ ≈ $4.82 /conso
PROD ×2 · backup ✓ 21:10 · 1 run yourself (/prod)
APK v4 · 14:52 ✗ stale: 2 commits since the build · 96 MB
git for ou-on-en-est and apk-fraicheur; a signed-in gh to see PRs and CI; key-based ssh to follow a server.git clone https://github.com/DarkSawOktay/claude-mods ~/claude-mods
In ~/.claude/settings.json (your user settings, not a project's), one path per mod you want, separated by ::
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-mods/suivi-conso:~/claude-mods/ou-on-en-est:~/claude-mods/garde-prod:~/claude-mods/apk-fraicheur"
}
}
To try one mod once: claude --plugin-dir ~/claude-mods/garde-prod. To update: git pull in ~/claude-mods; Claude Code reloads a mod when its files change.
In /config, or under pluginConfigs in ~/.claude/settings.json:
{
"pluginConfigs": {
"suivi-conso": { "options": { "language": "en" } },
"garde-prod": { "options": { "language": "en", "prodHosts": "example.com,203.0.113.7", "requireBackup": true } },
"ou-on-en-est": { "options": { "language": "en", "vpsHost": "deploy@example.com", "vpsRepos": "api=/srv/api", "healthUrls": "https://example.com/" } },
"apk-fraicheur": { "options": { "language": "en", "appDir": "/home/me/projects/my-app", "copyTo": "/mnt/c/Users/me/Downloads" } }
}
}
Every mod has language: fr (default) or en.
garde-prod
prodHosts: production hostnames or IPs. Without them, only risky commands are flagged.backupPattern: regular expression for your own backup command, on top of pg_dump, mysqldump, mongodump.backupMaxAgeMinutes (120): how old a backup may be and still cover a write.requireBackup (off): refuse a production database write without a recent backup. Claude gets the reason and takes the backup first.ou-on-en-est
projectsRoot: every git repo directly inside is tracked. Empty: the current project and the repos next to it.extraRepos: other paths, comma-separated.vpsHost, vpsRepos: your server (key-based ssh) and name=path for each deployed repo. Read-only: a single ssh running git rev-parse and git status, every 10 minutes and on each /ou-on-en-est.healthUrls: pages whose HTTP status is shown.apk-fraicheur
appDir: the project followed when the current folder is not an Android app.copyTo: where /apk copy puts the APK./theme-mods graphite | olive | crepuscule | papier | contraste (provided by suivi-conso). The choice is kept across sessions and written to ~/.claude/mods-theme, which every mod reads. The rest of Claude Code follows /theme.
gh, the files of your project, and with ou-on-en-est one read-only ssh and curl per check. ou-on-en-est also runs git fetch, which only updates remote-tracking branches./apk copy copies the APK when you ask. garde-prod with requireBackup on refuses a production database write until a backup exists; that is the only time a mod blocks anything.claude plugin validate <mod>
claude plugin test <mod>
Rules of the repo, so that every mod can be published:
userConfig;messages(lang) table, in French and English;hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;next(e) drawn below);Issues and pull requests are welcome.
MIT © Oktay Gençer
hooks/register.tsx 445 lines1// Suivi d'utilisation : quota 5 h et 7 j, contexte, coût, prévision, conseils et thèmes.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Snapshot, ThemeName } from '../types'
6import type { Lang } from './logic'
7import {
8 addSample,
9 addSpend,
10 addToDay,
11 addTurn,
12 chargeLastTurn,
13 estimateTokens,
14 formatClock,
15 formatIn,
16 formatTokens,
17 formatUsd,
18 gauge,
19 isThemeName,
20 lastSevenDays,
21 mcpServerOf,
22 messages,
23 project,
24 sparkline,
25 spendTarget,
26 statusLine,
27 THEME_NAMES,
28 THEMES,
29 tips,
30 toLang,
31 turnTokens,
32 upsertSession,
33} from './logic'
34
35const PANE = 'conso'
36
37const snapshot = atom({ plugin: 'suivi-conso', key: 'snapshot' } as const, null)
38const samples = atom({ plugin: 'suivi-conso', key: 'samples' } as const, [])
39const turns = atom({ plugin: 'suivi-conso', key: 'turns' } as const, [])
40const spends = atom({ plugin: 'suivi-conso', key: 'spends' } as const, [])
41const slices = atom({ plugin: 'suivi-conso', key: 'slices' } as const, [])
42const mcp = atom({ plugin: 'suivi-conso', key: 'mcp' } as const, [])
43const usedServers = atom({ plugin: 'suivi-conso', key: 'usedServers' } as const, [])
44const days = atom({ plugin: 'suivi-conso', key: 'days' } as const, [])
45const sessions = atom({ plugin: 'suivi-conso', key: 'sessions' } as const, [])
46const theme = atom({ plugin: 'suivi-conso', key: 'theme' } as const, 'graphite')
47const isBandHidden = atom({ plugin: 'suivi-conso', key: 'isBandHidden' } as const, false)
48
49/** Seuils du quota 5 h déjà signalés, pour ne prévenir qu'une fois. */
50const alerted = new Set<string>()
51let startedAt = 0
52let cwd = ''
53let breakdownAt = 0
54let lang: Lang = 'fr'
55let m = messages(lang)
56
57type Measured = {
58 context: { tokens?: number; window: number; percent?: number }
59 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
60 cost?: { usd: number }
61}
62
63function toSnapshot(at: number, m: Measured): Snapshot {
64 const win = (kind: string) => {
65 const r = m.rateLimits.find(l => l.kind === kind)
66 return r ? { percentUsed: r.percentUsed, resetsAt: r.resetsAt } : undefined
67 }
68 return {
69 at,
70 contextTokens: m.context.tokens,
71 contextWindow: m.context.window,
72 contextPercent: m.context.percent,
73 five: win('five_hour'),
74 week: win('seven_day'),
75 usd: m.cost?.usd,
76 }
77}
78
79/** Relit la répartition du contexte (estimée localement, sans requête). */
80async function refreshBreakdown($: EngineInterface, now: number) {
81 if (now - breakdownAt < 60_000) return
82 breakdownAt = now
83 const usage = await $.session.usage({ breakdown: 'summary' })
84 const b = usage.context.breakdown
85 if (!b) return
86 await update($, slices, () =>
87 b.categories
88 .filter(c => c.kind === 'used' && c.tokens > 0)
89 .map(c => ({ name: c.name, tokens: c.tokens }))
90 .sort((x, y) => y.tokens - x.tokens),
91 )
92 const byServer = new Map<string, number>()
93 for (const t of b.mcpTools) {
94 if (t.isLoaded) byServer.set(t.serverName, (byServer.get(t.serverName) ?? 0) + t.tokens)
95 }
96 await update($, mcp, () =>
97 [...byServer].map(([name, tokens]) => ({ name, tokens })).sort((x, y) => y.tokens - x.tokens),
98 )
99}
100
101/** Intègre une nouvelle mesure : historique, prévision, alertes, ligne d'état. */
102async function absorb($: EngineInterface, m: Measured) {
103 const now = await $.clock.now()
104 const prev = await read($, snapshot)
105 const next = toSnapshot(now, m)
106 await update($, snapshot, () => next)
107
108 if (next.five) {
109 const five = next.five
110 await update($, samples, list => addSample(list, { at: now, percentUsed: five.percentUsed }))
111 }
112
113 const usdDelta = prev?.usd !== undefined && next.usd !== undefined ? next.usd - prev.usd : 0
114 const weekDelta =
115 prev?.week && next.week && next.week.percentUsed >= prev.week.percentUsed
116 ? next.week.percentUsed - prev.week.percentUsed
117 : 0
118 if (usdDelta > 0) await update($, turns, list => chargeLastTurn(list, usdDelta))
119 if (usdDelta > 0 || weekDelta > 0) {
120 const all = await update($, days, list => addToDay(list, now, weekDelta, usdDelta))
121 await $.store.set('days', all)
122 }
123 if (next.usd !== undefined && startedAt > 0) {
124 const row = {
125 startedAt,
126 cwd,
127 usd: next.usd,
128 maxContext: Math.max(next.contextTokens ?? 0, (await read($, sessions)).find(s => s.startedAt === startedAt)?.maxContext ?? 0),
129 }
130 const all = await update($, sessions, list => upsertSession(list, row))
131 await $.store.set('sessions', all)
132 }
133
134 $.ui.status(statusLine(next, lang))
135 await warn($, next)
136}
137
138/** Prévient une seule fois par seuil franchi. */
139async function warn($: EngineInterface, s: Snapshot) {
140 const once = (key: string, text: string) => {
141 if (alerted.has(key)) return
142 alerted.add(key)
143 $.ui.toast(text, { timeoutMs: 8000 })
144 }
145 const five = s.five
146 if (five) {
147 const window = five.resetsAt ?? 'w'
148 if (five.percentUsed >= 90) once(`90:${window}`, m.toast90(Math.round(100 - five.percentUsed)))
149 else if (five.percentUsed >= 75) once(`75:${window}`, m.toast75(Math.round(five.percentUsed)))
150 const p = project(await read($, samples), s.at, five.resetsAt)
151 if (p?.isBeforeReset && p.exhaustAt - s.at < 90 * 60_000) {
152 once(`proj:${window}`, m.toastPace(formatClock(p.exhaustAt)))
153 }
154 }
155 if (s.contextPercent !== undefined && s.contextPercent >= 80) {
156 once(`ctx:${startedAt}`, m.toastContext(Math.round(s.contextPercent)))
157 }
158}
159
160/** Écrit le thème dans ~/.claude/mods-theme, où les autres mods de claude-mods le lisent. */
161async function shareTheme($: EngineInterface, name: ThemeName) {
162 try {
163 const home = await $.env.get('HOME')
164 if (home) await $.fs.write(`${home}/.claude/mods-theme`, name)
165 } catch {
166 // Sans ce fichier, les autres mods gardent leur thème par défaut.
167 }
168}
169
170export const register: Register = (on, options) => {
171 lang = toLang(options.language)
172 m = messages(lang)
173 const tok = (n: number) => formatTokens(n, lang)
174 const usd = (n: number) => formatUsd(n, lang)
175
176 on('session.start', async ($, e, next) => {
177 cwd = e.cwd
178 await $.command.register({
179 name: 'conso',
180 description: m.consoDesc,
181 argumentHint: m.consoHint,
182 })
183 await $.command.register({
184 name: 'theme-mods',
185 description: m.themeDesc(THEME_NAMES.join(', ')),
186 argumentHint: m.themeHint,
187 })
188
189 const savedTheme = await $.store.get('theme')
190 if (typeof savedTheme === 'string' && isThemeName(savedTheme)) {
191 await update($, theme, () => savedTheme)
192 await shareTheme($, savedTheme)
193 }
194 const savedDays = await $.store.get('days')
195 if (Array.isArray(savedDays)) await update($, days, () => savedDays)
196 const savedSessions = await $.store.get('sessions')
197 if (Array.isArray(savedSessions)) await update($, sessions, () => savedSessions)
198
199 const usage = await $.session.usage()
200 startedAt = usage.startedAt
201 await absorb($, usage)
202
203 return next(e)
204 })
205
206 on('session.measure', async ($, e, next) => {
207 await absorb($, e)
208 if (e.changed.includes('context')) await refreshBreakdown($, await $.clock.now())
209 return next(e)
210 })
211
212 on('turn.complete', async ($, e, next) => {
213 if (!e.agentId && e.usage) {
214 const usage = e.usage
215 const at = await $.clock.now()
216 await update($, turns, list => addTurn(list, { at, tokens: turnTokens(usage) }))
217 }
218 return next(e)
219 })
220
221 on('tool.call', async ($, e, next) => {
222 const ran = await next(e)
223 if (ran.deny !== undefined) return ran
224 const tool = String(e.tool)
225 const server = mcpServerOf(tool)
226 if (server) await update($, usedServers, list => (list.includes(server) ? list : [...list, server]))
227 const tokens = estimateTokens(ran.text ?? ran.result)
228 if (tokens > 0) {
229 const { key, label } = spendTarget(tool, e as unknown as Record<string, unknown>, lang)
230 await update($, spends, list => addSpend(list, key, label, tokens))
231 }
232 return ran
233 })
234
235 on('command.run', { command: 'conso' }, async ($, e) => {
236 if (['bande', 'band'].includes(e.args.trim())) {
237 const hidden = await update($, isBandHidden, v => !v)
238 return { text: hidden ? m.bandHidden : m.bandShown }
239 }
240 breakdownAt = 0
241 await refreshBreakdown($, await $.clock.now())
242 const opened = await $.ui.open({ id: PANE, title: m.paneTitle })
243 const line = statusLine(await read($, snapshot), lang) ?? m.noMeasure
244 return { text: opened.isPlaced ? m.paneOpened(line) : line }
245 })
246
247 on('command.run', { command: 'theme-mods' }, async ($, e) => {
248 const name = e.args.trim().toLowerCase()
249 if (!isThemeName(name)) {
250 const current = await read($, theme)
251 return { text: m.themeCurrent(current, THEME_NAMES.join(', ')) }
252 }
253 await update($, theme, () => name)
254 await $.store.set('theme', name)
255 await shareTheme($, name)
256 return { text: m.themeSet(name) }
257 })
258
259 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260 const s = await read($, snapshot)
261 if (e.props.hasSurvey || !s || (await read($, isBandHidden))) return next(e)
262 const { Box, Text } = $.ui.resolve(e)
263 const c = THEMES[(await read($, theme)) as ThemeName]
264 const p = s.five ? project(await read($, samples), s.at, s.five.resetsAt) : null
265 const isWide = e.props.bodyColumns >= 90
266 const sep = <Text color={c.dim}> │ </Text>
267 // Les autres mods (et l'engine) dessinent leur ligne en dessous de la nôtre.
268 const below = await next(e)
269
270 return (
271 <Box flexDirection="column">
272 <Box flexDirection="row" flexWrap="wrap">
273 {s.five && (
274 <Text>
275 <Text color={c.acc}>5 h </Text>
276 {isWide && <Text color={c.series[0]}>{gauge(s.five.percentUsed)} </Text>}
277 <Text>{m.left(Math.round(100 - s.five.percentUsed))}</Text>
278 {p?.isBeforeReset && <Text color={c.warn}> ⚠ {formatClock(p.exhaustAt)}</Text>}
279 </Text>
280 )}
281 {s.week && sep}
282 {s.week && (
283 <Text>
284 <Text color={c.acc}>{m.week} </Text>
285 <Text>{Math.round(100 - s.week.percentUsed)} %</Text>
286 </Text>
287 )}
288 {s.contextTokens !== undefined && sep}
289 {s.contextTokens !== undefined && (
290 <Text>
291 <Text color={c.acc}>ctx </Text>
292 <Text color={(s.contextPercent ?? 0) >= 80 ? c.warn : undefined}>
293 {tok(s.contextTokens)} / {tok(s.contextWindow)}
294 </Text>
295 </Text>
296 )}
297 {s.usd !== undefined && sep}
298 {s.usd !== undefined && <Text>≈ {usd(s.usd)}</Text>}
299 {isWide && <Text color={c.dim}> /conso</Text>}
300 </Box>
301 {below}
302 </Box>
303 )
304 })
305
306 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307 const { Box, Text } = $.ui.resolve(e)
308 const c = THEMES[(await read($, theme)) as ThemeName]
309 const s = await read($, snapshot)
310 const now = await $.clock.now()
311 const sampleList = await read($, samples)
312 const turnList = await read($, turns)
313 const spendList = await read($, spends)
314 const sliceList = await read($, slices)
315 const mcpList = await read($, mcp)
316 const used = await read($, usedServers)
317 const week = lastSevenDays(await read($, days), now)
318 const pastSessions = await read($, sessions)
319 const p = s?.five ? project(sampleList, now, s.five.resetsAt) : null
320 const width = Math.max(10, Math.min(24, e.props.bodyColumns - 22))
321
322 const title = (text: string) => (
323 <Text color={c.dim} bold>
324 {text.toUpperCase()}
325 </Text>
326 )
327 const row = (label: string, value: string, color?: string) => (
328 <Box flexDirection="row" justifyContent="space-between">
329 <Text wrap="truncate-end">{label}</Text>
330 <Text color={color ?? c.dim}>{value}</Text>
331 </Box>
332 )
333
334 if (!s) {
335 return (
336 <Box flexDirection="column">
337 <Text color={c.dim}>{m.noMeasurePane}</Text>
338 </Box>
339 )
340 }
341
342 const advice = tips({ snapshot: s, projection: p, spends: spendList, mcp: mcpList, usedServers: used }, lang)
343 const top = spendList.slice(0, 5)
344 const costly = [...pastSessions].sort((a, b) => b.usd - a.usd)[0]
345
346 return (
347 <Box flexDirection="column" gap={1}>
348 {s.five && (
349 <Box flexDirection="column">
350 {title(m.fiveTitle)}
351 <Text>
352 <Text color={c.series[0]}>{gauge(s.five.percentUsed, width)}</Text>
353 <Text bold> {m.left(Math.round(100 - s.five.percentUsed))}</Text>
354 </Text>
355 {s.five.resetsAt && (
356 <Text color={c.dim}>
357 {m.resetAt(formatClock(Date.parse(s.five.resetsAt)), formatIn(Date.parse(s.five.resetsAt) - now, lang))}
358 </Text>
359 )}
360 {p && (
361 <Text color={p.isBeforeReset ? c.warn : c.ok}>
362 {p.isBeforeReset
363 ? m.paceBad(Math.round(p.ratePerHour), formatClock(p.exhaustAt))
364 : m.paceOk(Math.round(p.ratePerHour))}
365 </Text>
366 )}
367 </Box>
368 )}
369
370 {s.week && (
371 <Box flexDirection="column">
372 {title(m.weekTitle)}
373 <Text>
374 <Text color={c.series[0]}>{gauge(s.week.percentUsed, width)}</Text>
375 <Text bold> {m.left(Math.round(100 - s.week.percentUsed))}</Text>
376 </Text>
377 {s.week.resetsAt && (
378 <Text color={c.dim}>
379 {m.weekReset(
380 new Date(Date.parse(s.week.resetsAt)).toLocaleDateString(m.locale, { weekday: 'short', day: 'numeric' }),
381 formatClock(Date.parse(s.week.resetsAt)),
382 )}
383 </Text>
384 )}
385 </Box>
386 )}
387
388 <Box flexDirection="column">
389 {title(m.contextTitle)}
390 <Text>
391 <Text bold>{tok(s.contextTokens ?? 0)}</Text>
392 <Text color={c.dim}> / {tok(s.contextWindow)}{s.contextPercent !== undefined ? ` · ${Math.round(s.contextPercent)} %` : ''}</Text>
393 </Text>
394 {sliceList.slice(0, 4).map((sl, i) => row(`■ ${sl.name}`, tok(sl.tokens), c.series[i]))}
395 </Box>
396
397 <Box flexDirection="column">
398 {title(m.sessionTitle)}
399 {s.usd !== undefined && row(m.cost, usd(s.usd), c.acc)}
400 {row(m.turns, String(turnList.length))}
401 {turnList.length > 1 && (
402 <Text>
403 <Text color={c.series[0]}>{sparkline(turnList.slice(-width).map(t => t.tokens))}</Text>
404 <Text color={c.dim}>{m.tokensPerTurn}</Text>
405 </Text>
406 )}
407 </Box>
408
409 {top.length > 0 && (
410 <Box flexDirection="column">
411 {title(m.topTitle)}
412 {top.map(sp => row(sp.count > 1 ? `${sp.label} (×${sp.count})` : sp.label, `~${tok(sp.tokens)}`))}
413 </Box>
414 )}
415
416 {advice.length > 0 && (
417 <Box flexDirection="column">
418 {title(m.tipsTitle)}
419 {advice.slice(0, 4).map(t => (
420 <Text>
421 <Text color={t.level === 'warn' ? c.warn : c.dim}>● </Text>
422 <Text bold>{t.title}</Text>
423 <Text>{lang === 'en' ? ': ' : ' : '}{t.text}</Text>
424 </Text>
425 ))}
426 </Box>
427 )}
428
429 <Box flexDirection="column">
430 {title(m.historyTitle)}
431 <Text>
432 <Text color={c.series[0]}>{sparkline(week.map(d => d.weekPercent))}</Text>
433 <Text color={c.dim}> {week.map(d => Math.round(d.weekPercent)).join(' · ')}</Text>
434 </Text>
435 {costly && costly.usd > 0 && (
436 <Text color={c.dim}>
437 {m.costliest(costly.cwd.split('/').pop() ?? '', usd(costly.usd), tok(costly.maxContext))}
438 </Text>
439 )}
440 </Box>
441 </Box>
442 )
443 })
444}
445hooks/logic.ts 447 lines1// Calculs purs du suivi : rien ici ne touche à l'engine, tout se teste seul.
2import type {
3 DayRow,
4 McpServer,
5 Sample,
6 SessionRow,
7 Snapshot,
8 Spend,
9 ThemeName,
10 TurnRow,
11} from '../types'
12
13const MINUTE = 60_000
14const HOUR = 60 * MINUTE
15
16/** Couleurs d'un thème : texte, accent, états et quatre teintes de graphique. */
17export type Palette = {
18 acc: string
19 ok: string
20 warn: string
21 bad: string
22 dim: string
23 series: [string, string, string, string]
24}
25
26const DARK_SERIES: Palette['series'] = ['#3987e5', '#d95926', '#199e70', '#c98500']
27
28export const THEMES: Record<ThemeName, Palette> = {
29 graphite: { acc: '#7cc4fa', ok: '#4cc38a', warn: '#e6ad4c', bad: '#e5675e', dim: '#8c95a2', series: DARK_SERIES },
30 olive: { acc: '#d6a94e', ok: '#62c08c', warn: '#e6ad4c', bad: '#e2665d', dim: '#80968a', series: DARK_SERIES },
31 crepuscule: { acc: '#f4a988', ok: '#6fd3a8', warn: '#e9c46a', bad: '#ef6f6c', dim: '#9d94b0', series: DARK_SERIES },
32 papier: {
33 acc: '#2f62c8',
34 ok: '#1f7a4d',
35 warn: '#9a6200',
36 bad: '#c23b32',
37 dim: '#676b72',
38 series: ['#2f62c8', '#c2571a', '#1f8a5a', '#8a4bb8'],
39 },
40 contraste: { acc: '#ffd400', ok: '#5cff7a', warn: '#ffd400', bad: '#ff5c5c', dim: '#cfcfcf', series: DARK_SERIES },
41}
42
43export const THEME_NAMES = Object.keys(THEMES) as ThemeName[]
44
45export function isThemeName(name: string): name is ThemeName {
46 return (THEME_NAMES as string[]).includes(name)
47}
48
49export type Lang = 'fr' | 'en'
50
51/** La langue d'un réglage : `en`, sinon le français. */
52export function toLang(value: unknown): Lang {
53 return value === 'en' ? 'en' : 'fr'
54}
55
56/** 342 000 → « 342 k », 1 000 000 → « 1 M » ; 1 500 → « 1,5 k » (fr) ou « 1.5 k » (en). */
57export function formatTokens(n: number, lang: Lang = 'fr'): string {
58 if (n >= 1_000_000) return `${trimZero((n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1), lang)} M`
59 if (n >= 10_000) return `${Math.round(n / 1000)} k`
60 if (n >= 1000) return `${trimZero((n / 1000).toFixed(1), lang)} k`
61 return String(n)
62}
63
64/** 4.817 → « 4,82 $ » (fr) ou « $4.82 » (en). */
65export function formatUsd(usd: number, lang: Lang = 'fr'): string {
66 return lang === 'en' ? `$${usd.toFixed(2)}` : `${usd.toFixed(2).replace('.', ',')} $`
67}
68
69function trimZero(s: string, lang: Lang): string {
70 const t = s.replace(/\.0$/, '')
71 return lang === 'en' ? t : t.replace('.', ',')
72}
73
74/** Heure locale « 19:31 ». */
75export function formatClock(ms: number): string {
76 const d = new Date(ms)
77 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
78}
79
80/** « dans 2 h 08 » ou « dans 12 min » (« in 12 min » en anglais). */
81export function formatIn(ms: number, lang: Lang = 'fr'): string {
82 const minutes = Math.max(0, Math.round(ms / MINUTE))
83 const word = lang === 'en' ? 'in' : 'dans'
84 if (minutes < 60) return `${word} ${minutes} min`
85 return `${word} ${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, '0')}`
86}
87
88/** Une jauge en blocs : 38 % sur 10 cases → « ████░░░░░░ ». */
89export function gauge(percent: number, width = 10): string {
90 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
91 return '█'.repeat(filled) + '░'.repeat(width - filled)
92}
93
94/** Une ligne de barres verticales pour une série courte. */
95export function sparkline(values: number[]): string {
96 const ticks = '▁▂▃▄▅▆▇█'
97 const max = Math.max(...values, 1)
98 return values.map(v => ticks[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
99}
100
101/**
102 * Ajoute un point de la fenêtre 5 h. Une baisse veut dire que la fenêtre a
103 * été remise à zéro : on repart de ce point. On garde 90 minutes.
104 */
105export function addSample(samples: Sample[], sample: Sample): Sample[] {
106 const last = samples[samples.length - 1]
107 if (last && sample.percentUsed < last.percentUsed) return [sample]
108 return [...samples, sample].filter(s => sample.at - s.at <= 90 * MINUTE).slice(-120)
109}
110
111export type Projection = {
112 /** Points de quota consommés par heure, sur la période observée. */
113 ratePerHour: number
114 /** Heure où la fenêtre serait épuisée à ce rythme. */
115 exhaustAt: number
116 /** Vrai si l'épuisement arrive avant la remise à zéro. */
117 isBeforeReset: boolean
118}
119
120/**
121 * Projette l'épuisement de la fenêtre 5 h au rythme des 30 dernières minutes
122 * (au moins 10 minutes d'observation). `null` sans rythme mesurable.
123 */
124export function project(samples: Sample[], now: number, resetsAt?: string): Projection | null {
125 const recent = samples.filter(s => now - s.at <= 30 * MINUTE)
126 const first = recent[0]
127 const last = recent[recent.length - 1]
128 if (!first || !last || last.at - first.at < 10 * MINUTE) return null
129 const ratePerHour = (last.percentUsed - first.percentUsed) / ((last.at - first.at) / HOUR)
130 if (ratePerHour <= 0) return null
131 const exhaustAt = last.at + ((100 - last.percentUsed) / ratePerHour) * HOUR
132 const reset = resetsAt ? Date.parse(resetsAt) : Number.NaN
133 return { ratePerHour, exhaustAt, isBeforeReset: Number.isFinite(reset) ? exhaustAt < reset : true }
134}
135
136/** Total des tokens d'un tour, comme le compte l'API. */
137export function turnTokens(usage: {
138 input_tokens: number
139 output_tokens: number
140 cache_read_input_tokens: number
141 cache_creation_input_tokens: number
142}): number {
143 return (
144 usage.input_tokens +
145 usage.output_tokens +
146 usage.cache_read_input_tokens +
147 usage.cache_creation_input_tokens
148 )
149}
150
151/** Ajoute un tour ; on garde les 40 derniers. */
152export function addTurn(turns: TurnRow[], turn: TurnRow): TurnRow[] {
153 return [...turns, turn].slice(-40)
154}
155
156/** Range le coût d'une mesure sur le dernier tour qui n'en a pas encore. */
157export function chargeLastTurn(turns: TurnRow[], usdDelta: number): TurnRow[] {
158 const i = turns.length - 1
159 if (i < 0 || turns[i]?.usd !== undefined || usdDelta <= 0) return turns
160 return turns.map((t, j) => (j === i ? { ...t, usd: usdDelta } : t))
161}
162
163/** Taille approximative d'un résultat d'outil en tokens (4 caractères ≈ 1 token). */
164export function estimateTokens(value: unknown): number {
165 if (value === undefined || value === null) return 0
166 const text = typeof value === 'string' ? value : JSON.stringify(value)
167 return Math.ceil((text ?? '').length / 4)
168}
169
170/** Ce qu'un appel d'outil vise, pour regrouper les dépenses. */
171export function spendTarget(tool: string, input: Record<string, unknown>, lang: Lang = 'fr'): { key: string; label: string } {
172 const m = messages(lang)
173 const str = (v: unknown) => (typeof v === 'string' ? v : '')
174 if (tool === 'Read') {
175 const path = str(input.file_path)
176 return { key: `Read:${path}`, label: `${m.read} ${shortPath(path)}` }
177 }
178 if (tool === 'Bash') {
179 const cmd = str(input.command).replace(/\s+/g, ' ').trim()
180 const head = cmd.split(' ').slice(0, 3).join(' ')
181 return { key: `Bash:${head}`, label: `${m.command} ${clip(head, 40)}` }
182 }
183 if (tool === 'Agent' || tool === 'Task') {
184 return { key: 'Agent', label: m.subagents }
185 }
186 if (tool.startsWith('mcp__')) {
187 const server = mcpServerOf(tool) ?? tool
188 return { key: `mcp:${tool}`, label: `MCP ${server} · ${tool.split('__').pop()}` }
189 }
190 return { key: tool, label: tool }
191}
192
193/** `mcp__github__get_me` → `github`. */
194export function mcpServerOf(tool: string): string | null {
195 const parts = tool.split('__')
196 return parts.length >= 3 && parts[0] === 'mcp' ? (parts[1] ?? null) : null
197}
198
199function shortPath(path: string): string {
200 const parts = path.split('/').filter(Boolean)
201 return parts.slice(-2).join('/') || path
202}
203
204function clip(s: string, n: number): string {
205 return s.length > n ? `${s.slice(0, n - 1)}…` : s
206}
207
208/** Ajoute une dépense, regroupée par cible ; on garde les 60 plus grosses. */
209export function addSpend(spends: Spend[], key: string, label: string, tokens: number): Spend[] {
210 const found = spends.find(s => s.key === key)
211 const next = found
212 ? spends.map(s => (s.key === key ? { ...s, tokens: s.tokens + tokens, count: s.count + 1 } : s))
213 : [...spends, { key, label, tokens, count: 1 }]
214 return next.sort((a, b) => b.tokens - a.tokens).slice(0, 60)
215}
216
217/** Clé de jour locale « 2026-10-02 ». */
218export function dayKey(ms: number): string {
219 const d = new Date(ms)
220 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
221}
222
223/**
224 * Ajoute à aujourd'hui ce qui a été consommé depuis la mesure précédente :
225 * les points du quota semaine (ignorés si la fenêtre a été remise à zéro)
226 * et les dollars. On garde 14 jours.
227 */
228export function addToDay(
229 days: DayRow[],
230 now: number,
231 weekDelta: number,
232 usdDelta: number,
233): DayRow[] {
234 const day = dayKey(now)
235 const w = weekDelta > 0 ? weekDelta : 0
236 const u = usdDelta > 0 ? usdDelta : 0
237 if (w === 0 && u === 0) return days
238 const found = days.find(d => d.day === day)
239 const next = found
240 ? days.map(d => (d.day === day ? { ...d, weekPercent: d.weekPercent + w, usd: d.usd + u } : d))
241 : [...days, { day, weekPercent: w, usd: u }]
242 return next.sort((a, b) => a.day.localeCompare(b.day)).slice(-14)
243}
244
245/** Les 7 derniers jours, jours vides compris, du plus ancien à aujourd'hui. */
246export function lastSevenDays(days: DayRow[], now: number): DayRow[] {
247 const out: DayRow[] = []
248 for (let i = 6; i >= 0; i -= 1) {
249 const day = dayKey(now - i * 24 * HOUR)
250 out.push(days.find(d => d.day === day) ?? { day, weekPercent: 0, usd: 0 })
251 }
252 return out
253}
254
255/** Met à jour la session courante dans la liste ; on garde les 30 dernières. */
256export function upsertSession(sessions: SessionRow[], row: SessionRow): SessionRow[] {
257 const rest = sessions.filter(s => s.startedAt !== row.startedAt)
258 return [...rest, row].sort((a, b) => a.startedAt - b.startedAt).slice(-30)
259}
260
261export type Tip = { level: 'warn' | 'info'; title: string; text: string }
262
263/** Les conseils, du plus utile au moins utile. */
264export function tips(input: {
265 snapshot: Snapshot | null
266 projection: Projection | null
267 spends: Spend[]
268 mcp: McpServer[]
269 usedServers: string[]
270}, lang: Lang = 'fr'): Tip[] {
271 const m = messages(lang)
272 const tok = (n: number) => formatTokens(n, lang)
273 const out: Tip[] = []
274 const { snapshot, projection, spends, mcp, usedServers } = input
275
276 if (projection?.isBeforeReset) {
277 out.push({
278 level: 'warn',
279 title: m.tipPaceTitle,
280 text: m.tipPace(Math.round(projection.ratePerHour), formatClock(projection.exhaustAt)),
281 })
282 }
283
284 const unused = mcp.filter(s => s.tokens >= 2000 && !usedServers.includes(s.name))
285 const unusedTokens = unused.reduce((n, s) => n + s.tokens, 0)
286 if (unusedTokens >= 5000) {
287 out.push({
288 level: 'warn',
289 title: m.tipMcpTitle,
290 text: m.tipMcp(unused.map(s => s.name).join(', '), tok(unusedTokens)),
291 })
292 }
293
294 const big = spends.filter(s => s.tokens / s.count >= 20_000).slice(0, 2)
295 for (const s of big) {
296 out.push({
297 level: 'warn',
298 title: m.tipBigTitle,
299 text: m.tipBig(s.label, tok(s.tokens)),
300 })
301 }
302
303 const reread = spends.filter(s => s.key.startsWith('Read:') && s.count >= 3).slice(0, 1)
304 for (const s of reread) {
305 out.push({
306 level: 'info',
307 title: m.tipRereadTitle,
308 text: m.tipReread(s.label, s.count, tok(s.tokens)),
309 })
310 }
311
312 const ctx = snapshot?.contextPercent
313 if (ctx !== undefined && ctx >= 60) {
314 out.push({
315 level: ctx >= 80 ? 'warn' : 'info',
316 title: m.tipContextTitle,
317 text: m.tipContext(Math.round(ctx)),
318 })
319 }
320
321 return out
322}
323
324/** La ligne d'état : « 5 h 62 % · 7 j 42 % · ctx 342 k/1 M · 4,82 $ ». */
325export function statusLine(s: Snapshot | null, lang: Lang = 'fr'): string | undefined {
326 if (!s) return undefined
327 const m = messages(lang)
328 const parts: string[] = []
329 if (s.five) parts.push(`5 h ${m.left(Math.round(100 - s.five.percentUsed))}`)
330 if (s.week) parts.push(`${m.week} ${Math.round(100 - s.week.percentUsed)} %`)
331 if (s.contextTokens !== undefined) parts.push(`ctx ${formatTokens(s.contextTokens, lang)}/${formatTokens(s.contextWindow, lang)}`)
332 if (s.usd !== undefined) parts.push(`≈ ${formatUsd(s.usd, lang)}`)
333 return parts.length ? parts.join(' · ') : undefined
334}
335
336const MESSAGES = {
337 fr: {
338 left: (n: number) => `${n} % restant`,
339 week: '7 j',
340 read: 'Lecture',
341 command: 'Commande',
342 subagents: 'Sous-agents',
343 tipPaceTitle: 'Rythme élevé',
344 tipPace: (rate: number, at: string) =>
345 `${rate} %/h : quota 5 h épuisé vers ${at}, avant la remise à zéro. Préfère des tâches courtes ou un effort plus bas.`,
346 tipMcpTitle: 'MCP inutilisés',
347 tipMcp: (names: string, tokens: string) =>
348 `${names} : ${tokens} tokens renvoyés à chaque requête sans servir. Coupe-les pour ce projet (/mcp).`,
349 tipBigTitle: 'Résultat volumineux',
350 tipBig: (label: string, tokens: string) => `${label} : ~${tokens}. Demande un extrait (fin du fichier, grep) plutôt que le tout.`,
351 tipRereadTitle: 'Fichier relu',
352 tipReread: (label: string, count: number, tokens: string) => `${label} lu ${count} fois (${tokens}).`,
353 tipContextTitle: 'Contexte chargé',
354 tipContext: (pct: number) => `${pct} % utilisé : /compact pour continuer, /clear si la tâche est finie.`,
355 toast90: (left: number) => `Quota 5 h : plus que ${left} %`,
356 toast75: (used: number) => `Quota 5 h : ${used} % utilisé`,
357 toastPace: (at: string) => `Au rythme actuel, quota 5 h épuisé vers ${at}`,
358 toastContext: (pct: number) => `Contexte à ${pct} % : pense à /compact`,
359 consoDesc: "Suivi d'utilisation : quota, contexte, coût et conseils (« /conso bande » masque ou montre la bande)",
360 consoHint: '[bande]',
361 bandWord: 'bande',
362 themeDesc: (names: string) => `Thème des mods : ${names}`,
363 themeHint: '<thème>',
364 bandHidden: 'Bande de suivi masquée.',
365 bandShown: 'Bande de suivi affichée.',
366 paneTitle: 'Utilisation',
367 noMeasure: 'Pas encore de mesure : elle arrive après la première réponse.',
368 paneOpened: (line: string) => `Panneau ouvert. ${line}`,
369 themeCurrent: (current: string, names: string) => `Thème actuel : ${current}. Choix : ${names} (ex. /theme-mods olive).`,
370 themeSet: (name: string) => `Thème des mods : ${name}.`,
371 noMeasurePane: 'Pas encore de mesure. Les chiffres arrivent après la première réponse de Claude.',
372 fiveTitle: 'Fenêtre 5 h',
373 resetAt: (at: string, inText: string) => `remise à zéro ${at} (${inText})`,
374 paceBad: (rate: number, at: string) => `⚠ ${rate} %/h : épuisé vers ${at}`,
375 paceOk: (rate: number) => `✓ ${rate} %/h : tu tiens jusqu'à la remise à zéro`,
376 weekTitle: 'Semaine (7 j)',
377 weekReset: (day: string, at: string) => `remise à zéro le ${day} à ${at}`,
378 locale: 'fr-FR',
379 contextTitle: 'Contexte',
380 sessionTitle: 'Cette session',
381 cost: 'Coût (équivalent API)',
382 turns: 'Tours',
383 tokensPerTurn: ' tokens par tour',
384 topTitle: 'Ce qui a le plus coûté',
385 tipsTitle: 'Pour économiser',
386 historyTitle: '7 derniers jours · % du quota semaine',
387 costliest: (name: string, usd: string, ctx: string) => `Session la plus chère : ${name} · ${usd} · ${ctx} de contexte`,
388 },
389 en: {
390 left: (n: number) => `${n}% left`,
391 week: '7 d',
392 read: 'Read',
393 command: 'Command',
394 subagents: 'Subagents',
395 tipPaceTitle: 'Fast pace',
396 tipPace: (rate: number, at: string) =>
397 `${rate}%/h: the 5 h quota runs out around ${at}, before the reset. Prefer short tasks or a lower effort.`,
398 tipMcpTitle: 'Unused MCP servers',
399 tipMcp: (names: string, tokens: string) =>
400 `${names}: ${tokens} tokens sent with every request and never used. Turn them off for this project (/mcp).`,
401 tipBigTitle: 'Large result',
402 tipBig: (label: string, tokens: string) => `${label}: ~${tokens}. Ask for an excerpt (end of file, grep) instead of the whole thing.`,
403 tipRereadTitle: 'File read again',
404 tipReread: (label: string, count: number, tokens: string) => `${label} read ${count} times (${tokens}).`,
405 tipContextTitle: 'Context filling up',
406 tipContext: (pct: number) => `${pct}% used: /compact to keep going, /clear if the task is done.`,
407 toast90: (left: number) => `5 h quota: only ${left}% left`,
408 toast75: (used: number) => `5 h quota: ${used}% used`,
409 toastPace: (at: string) => `At this pace, the 5 h quota runs out around ${at}`,
410 toastContext: (pct: number) => `Context at ${pct}%: consider /compact`,
411 consoDesc: 'Usage tracking: quota, context, cost and tips (“/conso band” hides or shows the band)',
412 consoHint: '[band]',
413 bandWord: 'band',
414 themeDesc: (names: string) => `Mods theme: ${names}`,
415 themeHint: '<theme>',
416 bandHidden: 'Usage band hidden.',
417 bandShown: 'Usage band shown.',
418 paneTitle: 'Usage',
419 noMeasure: 'No measurement yet: it arrives after the first reply.',
420 paneOpened: (line: string) => `Pane opened. ${line}`,
421 themeCurrent: (current: string, names: string) => `Current theme: ${current}. Choices: ${names} (e.g. /theme-mods olive).`,
422 themeSet: (name: string) => `Mods theme: ${name}.`,
423 noMeasurePane: "No measurement yet. Numbers arrive after Claude's first reply.",
424 fiveTitle: '5-hour window',
425 resetAt: (at: string, inText: string) => `resets at ${at} (${inText})`,
426 paceBad: (rate: number, at: string) => `⚠ ${rate}%/h: runs out around ${at}`,
427 paceOk: (rate: number) => `✓ ${rate}%/h: you will last until the reset`,
428 weekTitle: 'Week (7 d)',
429 weekReset: (day: string, at: string) => `resets ${day} at ${at}`,
430 locale: 'en-GB',
431 contextTitle: 'Context',
432 sessionTitle: 'This session',
433 cost: 'Cost (API equivalent)',
434 turns: 'Turns',
435 tokensPerTurn: ' tokens per turn',
436 topTitle: 'Most expensive',
437 tipsTitle: 'To save usage',
438 historyTitle: 'Last 7 days · % of weekly quota',
439 costliest: (name: string, usd: string, ctx: string) => `Most expensive session: ${name} · ${usd} · ${ctx} of context`,
440 },
441} as const
442
443/** Les textes affichés, dans la langue du réglage `language`. */
444export function messages(lang: Lang) {
445 return MESSAGES[lang]
446}
447types/index.d.ts 58 lines1/** Un instantané des chiffres que Claude Code mesure pour la session. */
2export type Snapshot = {
3 /** Moment de la mesure (ms, horloge de l'engine). */
4 at: number
5 contextTokens?: number
6 contextWindow: number
7 contextPercent?: number
8 /** Fenêtre de 5 h : pourcentage utilisé et heure de remise à zéro (ISO). */
9 five?: Window
10 /** Fenêtre de 7 jours. */
11 week?: Window
12 /** Coût de la session en dollars, équivalent API. */
13 usd?: number
14}
15
16export type Window = { percentUsed: number; resetsAt?: string }
17
18/** Un point de la fenêtre 5 h, pour calculer le rythme. */
19export type Sample = { at: number; percentUsed: number }
20
21/** Un tour de conversation terminé. */
22export type TurnRow = { at: number; tokens: number; usd?: number }
23
24/** Un poste de dépense : un résultat d'outil, regroupé par cible. */
25export type Spend = { key: string; label: string; tokens: number; count: number }
26
27/** Une catégorie de la répartition du contexte. */
28export type ContextSlice = { name: string; tokens: number }
29
30/** Un serveur MCP chargé dans le contexte. */
31export type McpServer = { name: string; tokens: number }
32
33/** Un jour de l'historique, gardé entre les sessions. */
34export type DayRow = { day: string; weekPercent: number; usd: number }
35
36/** Une session passée, pour retrouver les plus chères. */
37export type SessionRow = { startedAt: number; cwd: string; usd: number; maxContext: number }
38
39export type ThemeName = 'graphite' | 'olive' | 'crepuscule' | 'papier' | 'contraste'
40
41declare module 'claude-code' {
42 interface PluginState {
43 'suivi-conso': {
44 snapshot: Snapshot | null
45 samples: Sample[]
46 turns: TurnRow[]
47 spends: Spend[]
48 slices: ContextSlice[]
49 mcp: McpServer[]
50 usedServers: string[]
51 days: DayRow[]
52 sessions: SessionRow[]
53 theme: ThemeName
54 isBandHidden: boolean
55 }
56 }
57}
58