SLOPSHOPPER

suivi-conso

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…

newpanebandguardcommandtoast
★ 2v0.2.0MITupdated 2026-10-07DarkSawOktay/claude-mods/suivi-conso
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · suivi-conso
│ ┃ Utilisation ✕ › fix the failing auth test and add an audit log call │ ┃ FENÊTRE 5 H │ ┃ ███████░░░░░░░░░░░░░░░░░ 69 % restant ⏺ Read(src/auth.ts) │ ┃ remise à zéro NaN:NaN (dans NaN h NaN) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ CONTEXTE ⎿ Added 2 lines, removed 1 line │ ┃ 97 k / 200 k · 49 % ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ CETTE SESSION │ ┃ Coût (équivalent API) 0,42 $ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Tours 1 │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ CE QUI A LE PLUS COÛTÉ │ ┃ Lecture src/auth.ts ~49 › /conso │ ┃ Commande bun test ~40 ⎿ suivi-conso: Panneau ouvert. 5 h 69 % restant · ctx 97 k/200 k · │ ┃ Commande cat .env ~28 │ ┃ Write (×2) ~26 │ ┃ Edit ~12 │ ┃ │ ┃ 7 DERNIERS JOURS · % DU QUOTA SEMAINE │ ┃ ▁▁▁▁▁▁▁ 0 · 0 · 0 · 0 · 0 · 0 · 0 │ ┃ Session la plus chère : app · 0,42 $ · 97 k │ ┃ de contexte │ 5 h ███░░░░░░░ 69 % restant │ ctx 97 k / 200 k │ ≈ 0,42 $ /conso ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ suivi-conso: 5 h 69 % restant · ctx 97 k/200 k · ≈ 0,42 $

Draws

Band
5 h ███░░░░░░░ 69 % restant │ ctx 97 k / 200 k │ ≈ 0,42 $ /conso ⟨Claude Code's own drawing⟩
Pane · Utilisation
FENÊTRE 5 H ███████░░░░░░░░░░░░░░░░░ 69 % restant remise à zéro NaN:NaN (dans NaN h NaN) CONTEXTE 97 k / 200 k · 49 % CETTE SESSION Coût (équivalent API) 0,42 $ Tours 1 CE QUI A LE PLUS COÛTÉ Lecture src/auth.ts ~49 Commande bun test ~40 Commande cat .env ~28 Write (×2) ~26 Edit ~12 7 DERNIERS JOURS · % DU QUOTA SEMAINE ▁▁▁▁▁▁▁ 0 · 0 · 0 · 0 · 0 · 0 · 0 Session la plus chère : app · 0,42 $ · 97 k de contexte
README

claude-mods

tests license MIT

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.

ModWhat it showsCommands
suivi-conso — usage5-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 standFor 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 guardEvery 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 freshnessIs 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

Requirements

  • A recent Claude Code (tested with 2.1), in the terminal or the desktop app.
  • 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.

Install

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.

Settings

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.

Themes

/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.

What the mods do and don't do

  • They read: git, 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.
  • They never change your code, your branches or your server. /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.
  • Nothing leaves your machine: no telemetry, no third-party service.

Develop

claude plugin validate <mod>
claude plugin test <mod>

Rules of the repo, so that every mod can be published:

  • nothing specific to one install in the code: servers, paths and names go through userConfig;
  • every visible text goes through the mod's messages(lang) table, in French and English;
  • calculations live in hooks/logic.ts, with no engine call, tested on their own; rendering lives in hooks/register.tsx, tested on at least two surfaces;
  • a band above the prompt stacks with the other mods' (next(e) drawn below);
  • read-only by default: a mod that refuses or changes something does so behind an explicit setting.

Issues and pull requests are welcome.

License

MIT © Oktay Gençer


Version française

Source 3 files
hooks/register.tsx 445 lines
1// 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}
445
hooks/logic.ts 447 lines
1// 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}
447
types/index.d.ts 58 lines
1/** 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