Panneau de statistiques Claude Code (/ccstats) et bandeau au-dessus du prompt : discussion ouverte (coût, contexte, rythme horaire), récap de la journée, coût…

Panneau de statistiques Claude Code et bandeau au-dessus du prompt, sur le terminal comme sur le desktop. Il montre la discussion ouverte (coût, contexte, rythme horaire et son feu), le récap de la journée, le coût équivalent API, les tokens, les modèles, les projets, les sessions, les blocs de 5 h, un calendrier d'activité et le rythme de travail.
npx ccusage@latest (rapports claude daily, session et blocks, en JSON). Les prix sont ceux du barème en ligne, ou les prix embarqués de ccusage si le réseau manque.claude-usage), cloné dans ~/.claude/claude-usage, et lit ceux des autres : Portable (le PC Windows), Mac et Serveur (l'ancien PC, figé). Un même projet sur deux postes est reconnu à son nom de dossier : C:\dev\PolePredict et ~/dev/PolePredict ne font qu'un. Voir « Partage entre postes »./stats (~/.claude/stats-cache.json) et de ccusage, qui ne sont pas publiés.$.store) et, dans ~/.claude/claude-usage, le seul fichier de ce poste (machines/<nom>.json), qu'il commite et pousse.Version actuelle : 0.9.0. Historique des versions : CHANGELOG.md.
Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/stats-board/). Installation d'un poste et protocole de développement : README à la racine du dépôt.
claude plugin marketplace update mathis-plugins
claude plugin install stats-board@mathis-plugins
Pour une nouvelle version : modifier la source dans le clone de travail (plugins/stats-board), monter version dans .claude-plugin/plugin.json et dans l'entrée du mod de .claude-plugin/marketplace.json à la racine du dépôt, commiter et pousser, puis :
claude plugin marketplace update mathis-plugins
claude plugin update stats-board@mathis-plugins
Une mise à jour s'applique aux discussions démarrées après elle, ou après /reload-plugins.
Prérequis
node et npx dans le PATH) : ccusage s'installe à la volée par npx.git, un dépôt git privé à vous, accessible sans invite (gh auth setup-git), cloné dans ~/.claude/claude-usage, avec le nom du poste. /setup:install s'en charge. Sans eux, le mod marche pour ce poste seul et le dit dans l'en-tête./stats-board:ccstats (ou /ccstats) ouvre le panneau.Le panneau ne s'ouvre jamais seul : seulement par une commande ou par « ▸ /ccstats » dans le bandeau.
/stats-board:recap (ou une demande comme « combien m'a coûté cette discussion », « récap de la journée », « récap du parcours ») fait le récap dans le chat : voir Le skill recap.De haut en bas :
/cost), le remplissage du contexte, ses tours, son modèle, sa durée./ccstats discussion), le détail de ccusage sur cette seule discussion : tokens par type, frise du coût ou des tokens par tranche de 5 min à 2 j, empilée par modèle avec le cumul, modèles, appels les plus chers, sous-agents de workflow qu'elle a lancés./ccstats day revient au jour du relevé.scratch-workspaces) sont réunis en un seul projet. Un worktree de Claude (<dépôt>/.claude/worktrees/<nom>) compte pour le projet de son dépôt. Un projet travaillé sur plusieurs postes les additionne et dit la part des autres (« dont Mac …, Serveur … »).Les listes longues sont plafonnées (6 modèles, 8 projets, 10 sessions, 5 jours) avec « ▸ tout voir ».
Sur le desktop, VS Code et le mobile, les graphiques sont des SVG. Sur le terminal, le calendrier et les colonnes (courbes des cartes de coût, barres de l'Évolution et de la discussion, blocs de 5 h, heures de début) sont des grilles de cases (Raster, une par graphique) : chaque jour du calendrier est une case pleine à la teinte de son niveau (les opacités du SVG, posées sur un fond foncé), et dans une barre empilée la frontière entre deux modèles tombe au huitième de case (part du bas en avant-plan, part du haut en fond). Le reste du terminal reste en glyphes : courbes d'une ligne, jauges en blocs, pastilles, bandeau. Sans Raster dans la table de la surface, le calendrier revient à · ░ ▒ ▓ █ et les colonnes aux huitièmes de bloc en texte.
Une ligne, deux pendant un relevé.
Sur le terminal, compact (un émoji par libellé, « tok ») :
▍ Stats ▰▰▰▰ 🔥 44 $/h · 💬 47,12 $ 101 M tok · 🌞 134 $ 210 M tok · 📅 1 179 $ 2,3 Md tok ▸ /ccstats x
⠋ Relevé ccusage : sessions et blocs de 5 h…
Sur le desktop, en mots :
▍ Stats 🔥 44 $/h · discussion 47,12 $ 101 M · aujourd'hui 134 $ 210 M · oct. 1 179 $ 2,3 Md ▸ /ccstats x
Dans l'ordre :
/cost, mis à jour à chaque tour) et ses tokens (voir l'onglet Discussion).Les trois émojis occupent deux cellules et n'ont pas de sélecteur de variante : leur largeur est la même dans tous les terminaux qui affichent les émojis.
Les niveaux du rythme viennent des 45 blocs de 5 h du 9 septembre au 7 octobre 2026 (dépense par heure active : 1er quartile 6,9 $/h, médiane 13,5, 3e quartile 21,7, 9e décile 31,9, maximum 71). Ils sont fixés dans RATE_LEVELS (model.ts) :
| Niveau | Rythme | Feu |
|---|---|---|
| calme | < 7 $/h | flamme éteinte, crans vides |
| posé | 7 à 14 $/h | petite flamme jaune, 1 cran |
| soutenu | 14 à 22 $/h | flamme mandarine, 2 crans |
| intense | 22 à 32 $/h | grande flamme rouge-orangé, 3 crans |
| en feu | ≥ 32 $/h | trois flammes et un halo, 4 crans et 🔥 |
/ccstats show le réaffiche.Le bandeau s'empile avec ceux des autres mods (git-board, flow-board, asana-board) sans les effacer. En tête, une barre « ▍ » et le nom du mod en bleu ciel, alignés avec ceux des autres bandeaux.
| Commande | Effet |
|---|---|
/stats-board:ccstats | Ouvre le panneau. Connue dès le premier message. |
/ccstats | La même commande, déclarée au démarrage du mod. Masquée du menu, utilisable tapée en entier. |
/ccstats discussion | Ouvre l'onglet Discussion et, s'il n'y en a pas encore, lance le détail de ccusage sur la discussion ouverte. |
/ccstats day | Ouvre l'onglet Journée sur le jour du relevé (aussi journée, jour, today, aujourd'hui, récap). |
/ccstats overview, trend, models, projects, sessions, rhythm | Ouvre le panneau sur cet onglet (les noms français marchent aussi : aperçu, évolution, modèles, projets, rythme). |
/ccstats refresh | Ouvre le panneau et relance un relevé. |
/ccstats hide | Masque le bandeau ; la ligne d'état prend le relais. |
/ccstats show | Réaffiche le bandeau et ouvre le panneau. |
Pas de /stats : c'est le nom de la commande intégrée de Claude Code.
/stats-board:recap écrit le récap de l'usage dans la conversation, en Markdown. Claude lance skills/recap/recap.mjs avec l'identifiant de la discussion (${CLAUDE_SESSION_ID}), recopie sa sortie et ajoute au plus deux remarques. Tous les chiffres viennent du script.
| Partie | Contenu | Source |
|---|---|---|
| Cette discussion | Coût, tokens et part lue en cache, sortie, heures et durée, projet, coût par modèle, sous-agents de workflow (inclus) | ccusage claude session --since <7 jours> : sa ligne et celles de ses sous-agents |
| Aujourd'hui sur ce poste | Coût et tokens, coût par modèle, tableau des projets (coût, part, tokens) | ccusage claude daily --since <aujourd'hui> --instances --breakdown |
| Discussions actives aujourd'hui | Les 5 plus chères (projet, heures, coût total), le reste regroupé | Les sessions dont la dernière activité est aujourd'hui |
| Bloc de 5 h en cours | Coût, rythme et son niveau (mêmes seuils que le bandeau), projection | ccusage claude blocks --active |
| Autres postes | Coût et tokens du jour, heure de publication, total de tous les postes | ~/.claude/claude-usage/machines/*.json |
claude-usage.--json --timezone <fuseau>, nouvel essai avec --offline si le barème en ligne échoue). Une quinzaine de secondes au premier appel, moins d'une seconde quand ccusage est en cache./cost).cwd des transcriptions, à condition que ce chemin redonne la clé du projet. Un projet nommé dans names.json (voir « Partage entre postes ») prend ce nom ; un worktree de Claude de ce projet s'affiche « <nom> · <worktree> » ; sans nom dans names.json, un worktree garde son nom seul. Un names.json illisible est ignoré sans avertissement (le panneau, lui, le signale).fmtCost, fmtTokens…), les seuils de rythme (RATE_LEVELS) et la lecture de names.json (parseLabels, LABEL_MAX) sont recopiés de model.ts : les garder alignés à la main.Sur « récap du parcours », « combien a coûté le parcours », le skill totalise un parcours entier (discussion principale, sessions liées, sous-agents compris) sans chercher les identifiants à la main :
| Option | Sessions retenues |
|---|---|
--parcours "<préfixe>" | Celles dont le titre commence par le préfixe. Le titre est la dernière ligne custom-title de la transcription (après un renommage, c'est le nouveau), sinon la dernière ai-title, le titre automatique d'une session jamais renommée. Les sessions liées de flow-board portent « <titre du parcours, coupé au-delà de 28 signes>… › Groupe X · … » (linkedPrefix de flow-board) : une telle session est retenue aussi quand son début coupé commence le préfixe, si bien que le titre complet de la discussion principale suffit. Guillemets, «, », $, \ et accents graves sont ignorés de part et d'autre, comme flow-board les retire. |
--worktrees <dossier du projet> | Celles des worktrees groupe-* du projet (dossiers ~/.claude/projects/<projet>--claude-worktrees-groupe-*), plus la discussion principale si son identifiant est passé ; sinon le récap le signale. Lancé depuis un worktree, le dossier remonte au projet. |
--since AAAA-MM-JJ, --until AAAA-MM-JJ | La période : 60 jours jusqu'à aujourd'hui par défaut. Les worktrees groupe-* resservent d'un parcours à l'autre : avec --worktrees, donner les dates de début et de fin du parcours. |
Les deux premières options se combinent (réunion des sessions). Un argument inconnu (préfixe sans guillemets, --since=…) arrête le script. Le récap donne le coût total, les tokens, le coût par modèle, puis une ligne par session : titre, projet, heures de début et de fin, durée (de la première à la dernière activité, pauses comprises), coût. Il ne lit que ccusage claude session --since <début> [--until <fin>] : ni journée, ni bloc de 5 h, ni autres postes. ccusage 20 compte les sous-agents (dossier subagents/ d'une transcription) dans la session qui les a lancés. Les titres se lisent par blocs depuis la fin de chaque transcription.
| Donnée | Source | Portée |
|---|---|---|
| Coût, tokens par jour, projet et modèle | npx ccusage@latest claude daily --instances --breakdown --json | Ce poste, publié |
| Sessions (début, fin, coût, modèles) | npx ccusage@latest claude session --json | Ce poste, publié |
| Les autres postes | ~/.claude/claude-usage/machines/*.json (dépôt partagé) | Leurs lignes et sessions ; Serveur : totaux par jour sur PolePredict |
| Blocs de 5 h (rythme, projection) | npx ccusage@latest claude blocks --breakdown --json | Ce poste, tous projets |
| Messages, appels d'outils, heures | ~/.claude/stats-cache.json (cache de /stats, un jour de retard) | Ce poste, tous projets |
| Nom d'affichage d'un projet | ~/.claude/claude-usage/names.json (dépôt partagé), écrit à la main | Tous les postes ; jamais publié |
| Nom et dossier des projets | Le premier "cwd" des transcriptions (~/.claude/projects/<clé>/*.jsonl) qui redonne la clé du projet, à défaut le premier tout court, lu par Node | Gardé une fois trouvé ; relu s'il ne redonne pas la clé |
Chaque rapport passe --json --order asc --timezone <fuseau de la machine>. Le fuseau est lu par Node (Intl), qui donne aussi le jour local du mod.
| Relevé | Quand |
|---|---|
| Au démarrage | 3 s après, si le dernier relevé a plus de 20 min |
| À l'ouverture du panneau | Si le dernier relevé a plus de 5 min |
| Après un tour de la conversation | Si le dernier relevé a plus de 20 min |
« ↻ Actualiser », /ccstats refresh | Toujours |
Un relevé prend une quinzaine de secondes. Le premier rapport installe ccusage dans le cache de npx ; les deux suivants partent ensemble, avec la version de ccusage. Si le barème en ligne échoue, ccusage est relancé avec --offline, et l'en-tête affiche « prix hors ligne ».
| Donnée gardée | Emplacement | Portée |
|---|---|---|
| Dernier relevé | $.store (snapshot) et état de session | D'une session à l'autre |
| Onglet, période, machine, filtres, listes dépliées, bandeau masqué | État de session ($.state) | Une discussion |
Historique gardé. Claude Code efface ses journaux après cleanupPeriodDays jours, et ccusage ne voit alors plus ces jours. Le mod garde ceux de ce poste dans le relevé précédent qui sont plus anciens que le premier jour du nouveau, et le dit dans l'en-tête (« ↺ N jours gardés par le mod »). Il fait de même pour les sessions. Comme ce poste publie ce qu'il garde, son historique reste aussi dans claude-usage.
~/.claude/claude-usage/.machine (Portable, Mac…), propre au poste et ignorée par git. Sans nom, le mod s'affiche « Ce poste », ne publie rien et lit quand même les autres.machines/<nom>.json, seulement si ses lignes, sessions ou projets ont changé (l'heure seule ne compte pas) ;git add, puis git commit ;git pull --rebase, puis git push s'il y a quelque chose à pousser ;GIT_TERMINAL_PROMPT=0) : un échec (hors ligne, identifiants) devient un avertissement. Les fichiers déjà là sont lus quand même, et le relevé suivant réessaie.ssh-<id>, est écartée de ce poste quand celui où elle a tourné la publie : elle ne compte qu'une fois. Deux projets différents du même nom sont donc additionnés. Les brouillons du desktop gardent leur clé et se regroupent seuls.pull --rebase qui échoue est annulé (rebase --abort).index.lock), ou cannot lock ref quand son pull trouve origin/main déjà avancé par l'autre. Celle-ci réessaie deux fois, à 2 puis 4 s. Si ça bloque encore, elle laisse la publication à l'autre, sans avertissement. Seul un verrou d'index qui date de plus de 10 min (git arrêté en route) est signalé, avec son chemin.names.json, à la racine du dépôt de partage, donne un autre nom à un projet sans déplacer son dossier, par exemple { "qwen-image-local": "Chambre noire" }. La clé est le nom du dossier (sans la casse, le dernier segment de son chemin, aussi pour le dossier personnel), le même sur tous les postes ; la valeur, le nom affiché (60 caractères au plus). Le panneau et /stats-board:recap l'affichent partout, worktrees compris. Le rapprochement des postes et les fichiers publiés gardent le nom du dossier : retirer une ligne rend l'ancien nom au relevé suivant. Ce fichier s'écrit à la main, puis commit et push dans le dépôt de partage : une modification non commitée bloque la publication du poste (le pull --rebase refuse), avec un avertissement, jusqu'au commit. Un fichier illisible devient un avertissement de l'en-tête ; un BOM en tête (PowerShell) est accepté..machine, et renommer aussi son fichier publié (`machines/hooks/register.tsx 783 lines1// Panneau de statistiques Claude Code (/ccstats) et bandeau au-dessus du prompt : récap de la
2// journée, coût équivalent API, tokens, modèles, projets, sessions, blocs de 5 h, calendrier et rythme.
3//
4// - Ce module est le seul à parler au moteur (`$`) : état, hooks, ccusage, fichiers.
5// Le dessin est dans view.tsx, les données pures dans model.ts, les SVG dans svg.ts.
6// - Les prix viennent de ccusage (`npx ccusage@latest claude daily|session|blocks --json`) ;
7// les messages, appels d'outils et heures du cache de /stats (`~/.claude/stats-cache.json`).
8// - Partage entre postes : chaque poste publie ses lignes et ses sessions dans le dépôt git
9// `~/.claude/claude-usage` (`machines/<nom>.json`, nom du poste dans `.machine`) et lit ceux
10// des autres ; l'ancien serveur y a un fichier figé. Sans ce dépôt, le mod reste local.
11// - Un relevé prend une quinzaine de secondes : il part au démarrage si le dernier a plus de
12// 20 min, à l'ouverture du panneau s'il a plus de 5 min, après un tour s'il a plus de
13// 20 min, et sur « ↻ Actualiser ». Le dernier relevé est gardé dans `$.store`.
14// - `/stats-board:ccstats` (commands/ccstats.md) est connue dès le premier message ;
15// `/ccstats`, déclarée au démarrage, sort du menu mais reste utilisable tapée en entier.
16// Pas de `/stats` : la commande intégrée de Claude Code porte ce nom.
17
18import { atom, read, update } from 'claude-code'
19import type { Register } from 'claude-code'
20
21import type { Discussion, Meta, Notice, ProjectInfo, Published, SessionRow, Snapshot, Tab, Ui, UsageRow } from '../types'
22import {
23 LOCAL_LABEL,
24 SCHEMA,
25 activityOf,
26 blocksOf,
27 cwdOf,
28 dayOf,
29 discussionOf,
30 fmtCost,
31 isStale,
32 keepHistory,
33 keepSessions,
34 machineFile,
35 mergeMachines,
36 nameOfPath,
37 ownHistory,
38 parseJson,
39 parseLabels,
40 parsePublished,
41 projectKey,
42 publishedOf,
43 rowsOfDaily,
44 sessionsOf,
45 statusText,
46 sum,
47 unique,
48 unpricedOf,
49 withLabels,
50} from './model.ts'
51import { drawBand, drawPane, kitOf, type Actions, type Caps, type View } from './view.tsx'
52
53const PANE = 'ccstats'
54const PANE_TITLE = 'Stats Claude Code'
55/** La commande déclarée par `commands/ccstats.md`, préfixée du nom du plugin. */
56const STATIC_CMD = 'stats-board:ccstats'
57const ARG_HINT = '[discussion|day|overview|trend|models|projects|sessions|rhythm|refresh|hide|show]'
58const TAB_LABEL: Record<Tab, string> = { discussion: 'Discussion', day: 'Journée', overview: 'Aperçu', trend: 'Évolution', models: 'Modèles', projects: 'Projets', sessions: 'Sessions', rhythm: 'Rythme' }
59const TAB_WORDS: Record<string, Tab> = {
60 discussion: 'discussion',
61 day: 'day',
62 today: 'day',
63 jour: 'day',
64 journee: 'day',
65 journée: 'day',
66 aujourdhui: 'day',
67 "aujourd'hui": 'day',
68 recap: 'day',
69 récap: 'day',
70 overview: 'overview',
71 apercu: 'overview',
72 aperçu: 'overview',
73 trend: 'trend',
74 evolution: 'trend',
75 évolution: 'trend',
76 costs: 'trend',
77 couts: 'trend',
78 coûts: 'trend',
79 models: 'models',
80 modeles: 'models',
81 modèles: 'models',
82 projects: 'projects',
83 projets: 'projects',
84 sessions: 'sessions',
85 rhythm: 'rhythm',
86 rythme: 'rhythm',
87}
88
89/** Clés de `$.store`, d'une session à l'autre. */
90const STORE_SNAPSHOT = 'snapshot'
91/** Relevé automatique (démarrage, fin de tour) quand le dernier a plus de 20 min. */
92const AUTO_MS = 20 * 60_000
93/** Relevé à l'ouverture du panneau quand le dernier a plus de 5 min. */
94const OPEN_MS = 5 * 60_000
95/** Le dépôt git partagé entre les postes, sous le dossier de Claude Code. */
96const USAGE_DIR = 'claude-usage'
97/** Le nom de ce poste, une ligne, propre au poste (ignoré par git dans le dépôt partagé). */
98const MACHINE_FILE = '.machine'
99/** Les noms d'affichage des projets, communs aux postes, à la racine du dépôt partagé. */
100const LABELS_FILE = 'names.json'
101/** git sans invite : un push sans identifiants échoue au lieu d'attendre. */
102const GIT_ENV = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
103/** Plafond du relevé gardé dans `$.store` (4 Mio en tout pour le plugin). */
104const STORE_LIMIT = 3_000_000
105
106const EMPTY_UI: Ui = {
107 tab: 'overview',
108 period: 'all',
109 machine: 'all',
110 metric: 'cost',
111 grain: 'day',
112 project: null,
113 model: null,
114 sessionSort: 'cost',
115 expanded: { models: false, projects: false, sessions: false, days: false },
116 day: null,
117}
118const EMPTY_META: Meta = { loading: null, error: null, startedAt: null }
119const EMPTY_DISCUSSION: Discussion = { id: null, live: null, detail: null, turns: [], loading: false, error: null }
120
121// Gardés par l'hôte : survivent au rechargement à chaud du module.
122const snapshot = atom({ plugin: 'stats-board', key: 'snapshot' } as const, null as Snapshot | null)
123const ui = atom({ plugin: 'stats-board', key: 'ui' } as const, EMPTY_UI)
124const meta = atom({ plugin: 'stats-board', key: 'meta' } as const, EMPTY_META)
125const notice = atom({ plugin: 'stats-board', key: 'notice' } as const, null as Notice)
126// Bandeau masqué : le temps de la session, comme git-board.
127const isBandHidden = atom({ plugin: 'stats-board', key: 'isBandHidden' } as const, false)
128const here = atom({ plugin: 'stats-board', key: 'here' } as const, null as string | null)
129const discussion = atom({ plugin: 'stats-board', key: 'discussion' } as const, EMPTY_DISCUSSION)
130
131// Le contrat `Elements` du moteur : pas de `Svg` sur le terminal. La table de `$.ui.resolve`
132// le contient quand même (fragment vide) : on ne peut pas s'y fier.
133const capsOf = (surface: string): Caps => ({ hasSvg: surface !== 'terminal' })
134
135type Run = { exitCode: number; stdout: string; stderr: string }
136type Json = Record<string, any>
137
138// État du module, perdu au rechargement : rien que des optimisations.
139let inFlight: Promise<void> | null = null
140let detailInFlight: Promise<void> | null = null
141/** Le lanceur de npx qui a marché : `npx` direct, `npx.cmd`, ou `cmd.exe /c npx` sous Windows. */
142let launcher: string[] | null = null
143let ticks = 0
144
145/** Un effet d'interface raté (pas d'écran, surface sans panneau) ne doit jamais gêner le relevé. */
146const quietly = async (effect: () => unknown) => {
147 try {
148 await effect()
149 } catch {
150 // Rien à afficher ici : l'état, lui, est déjà écrit.
151 }
152}
153
154const firstLine = (text: string): string =>
155 text
156 .split(/\r?\n/)
157 .map((l) => l.trim())
158 .find((l) => l && !/^\s*$/.test(l)) ?? ''
159
160const messageOf = (err: unknown): string => String((err as { message?: unknown })?.message ?? err)
161
162// ─── Commande /ccstats et /stats-board:ccstats ──────────────────────────────
163
164// `/ccstats` n'existe qu'après le démarrage du mod ; `/stats-board:ccstats`, déclarée par
165// `commands/ccstats.md`, est connue dès le premier message. Les deux reçoivent la même
166// réponse, sans passer par le modèle. Aucun état posé par `session.start` n'est supposé ici.
167const answer = async ($: any, e: { args: string }) => {
168 const word = e.args.trim().toLowerCase()
169 if (word === 'hide') {
170 await setBandHidden($, true)
171 return { text: 'Bandeau Stats masqué (/ccstats show pour le réafficher).' }
172 }
173 let text = 'Panneau Stats ouvert.'
174 if (word === 'show') {
175 await setBandHidden($, false)
176 text = 'Bandeau Stats réaffiché, panneau Stats ouvert.'
177 }
178 const tab = TAB_WORDS[word]
179 if (tab) {
180 // `/ccstats day` montre la journée du relevé, même après un jour choisi dans l'onglet.
181 await update($, ui, (x) => ({ ...x, tab, ...(tab === 'day' ? { day: null } : {}) }))
182 text = `Panneau Stats ouvert (onglet ${TAB_LABEL[tab]}).`
183 // L'onglet Discussion sans détail encore : ccusage sur cette seule discussion.
184 if (tab === 'discussion' && !(await read($, discussion)).detail) $.clock.after(0, () => void refreshDiscussion($))
185 }
186 await quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
187 const s = await read($, snapshot)
188 const isForced = word === 'refresh'
189 if (isForced) text = 'Panneau Stats ouvert : relevé ccusage relancé.'
190 // Par une minuterie : le relevé (une quinzaine de secondes) survit à la fin de la commande.
191 if (isForced || !s || (await $.clock.now()) - s.fetchedAt > OPEN_MS) $.clock.after(0, () => void refresh($, isForced))
192 return { text }
193}
194
195// `/ccstats` reste utilisable tapée en entier, mais sort du menu : `/stats-board:ccstats`,
196// déjà là avant le premier message, y suffit et le doublon disparaît.
197const hideDuplicate = async ($: any, e: any, next: any) => ({ ...(await next(e)), isHidden: true })
198
199export const register: Register = (on) => {
200 on('session.start', async ($, e, next) => {
201 const result = await next(e)
202 // Un enregistrement refusé ne doit empêcher ni la minuterie ni le relevé.
203 await quietly(() =>
204 $.command.register({
205 name: 'ccstats',
206 description: 'Statistiques Claude Code : journée, coût (ccusage), tokens, modèles, projets, sessions, rythme',
207 argumentHint: ARG_HINT,
208 }),
209 )
210 // « Ce projet » : la clé ccusage du dossier de la session.
211 const cwd = await Promise.resolve($.session.cwd()).catch(() => null)
212 if (typeof cwd === 'string' && cwd) await update($, here, () => projectKey(cwd))
213 // Un relevé d'un autre format (gardé par l'hôte au rechargement) est écarté, puis refait.
214 const current = await read($, snapshot)
215 if (current && !isSnapshot(current)) await update($, snapshot, () => null)
216 // Le dernier relevé, gardé d'une session à l'autre : bandeau et panneau l'ont tout de suite.
217 if (!(await read($, snapshot))) {
218 const stored = await Promise.resolve($.store.get(STORE_SNAPSHOT)).catch(() => undefined)
219 if (isSnapshot(stored)) await update($, snapshot, () => stored)
220 }
221 // La discussion ouverte : coût et contexte d'après le moteur, tout de suite.
222 await quietly(() => readLive($))
223 // Toutes les 5 s : les secondes d'un relevé en cours ; à la minute, les âges et le bloc de 5 h.
224 $.clock.every(5_000, () => void tick($))
225 await syncStatus($)
226 const s = await read($, snapshot)
227 if (!s || (await $.clock.now()) - s.fetchedAt > AUTO_MS) $.clock.after(3_000, () => void refresh($, false))
228 return result
229 })
230
231 on('command.run', { command: 'ccstats' }, answer)
232 on('command.describe', { command: 'ccstats' }, hideDuplicate)
233 // En panne, le mod s'efface : la commande statique dit alors qu'il n'est pas actif.
234 on('command.run', { command: STATIC_CMD }, answer).catch(() => undefined)
235
236 // Après un tour de la conversation principale : ses tokens et le coût du moteur pour la
237 // discussion, puis un relevé si le dernier a vieilli.
238 on('turn.complete', async ($, e, next) => {
239 const result = await next(e)
240 if (!e.agentId) {
241 const u = (result as { usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } }).usage
242 if (u) {
243 const tokens = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
244 const at = await $.clock.now()
245 await update($, discussion, (d) => ({ ...d, turns: [...d.turns, { at, tokens }].slice(-500) }))
246 }
247 await quietly(() => readLive($))
248 const s = await read($, snapshot)
249 if (s && (await $.clock.now()) - s.fetchedAt > AUTO_MS) $.clock.after(1_000, () => void refresh($, false))
250 }
251 return result
252 })
253
254 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255 const view = await readView($)
256 const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
257 return drawPane(kit, view, actionsFor($)) as any
258 })
259
260 // Le bandeau se compose avec ceux des autres plugins (git-board, flow-board…) : le leur reste
261 // au-dessus, celui de Stats dessous ; jamais seul quand un autre bandeau existe.
262 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
263 if (e.props.hasSurvey) return next(e)
264 // Lire AVANT tout retour anticipé : la lecture abonne le bandeau aux changements.
265 const view = await readView($)
266 if (view.isBandHidden) return next(e)
267 const below = await next(e)
268 const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
269 const own = drawBand(kit, view, actionsFor($)) as any
270 if (!own) return below
271 if (below.type === 'engine') return own
272 const { Box } = kit
273 return (
274 <Box flexDirection="column" width={e.props.bodyColumns}>
275 {below}
276 {own}
277 </Box>
278 )
279 })
280}
281
282// ─── Vue et actions ─────────────────────────────────────────────────────────
283
284async function readView($: any): Promise<View> {
285 return {
286 snapshot: await read($, snapshot),
287 ui: await read($, ui),
288 meta: await read($, meta),
289 notice: await read($, notice),
290 isBandHidden: await read($, isBandHidden),
291 here: await read($, here),
292 discussion: await read($, discussion),
293 now: await $.clock.now(),
294 }
295}
296
297function actionsFor($: any): Actions {
298 return {
299 setUi: (patch) => update($, ui, (x) => ({ ...x, ...patch })),
300 toggleExpanded: (list) => update($, ui, (x) => ({ ...x, expanded: { ...x.expanded, [list]: !x.expanded[list] } })),
301 // ↻ de l'en-tête : le relevé complet et, en même temps, le détail de la discussion.
302 refresh: () => Promise.all([refresh($, true), refreshDiscussion($)]),
303 refreshDiscussion: () => refreshDiscussion($),
304 dismissNotice: () => update($, notice, () => null),
305 hideBand: async () => {
306 await setBandHidden($, true)
307 await quietly(() => $.ui.toast('Bandeau Stats masqué : /ccstats show pour le réafficher'))
308 },
309 openPane: async () => {
310 await quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
311 const s = await read($, snapshot)
312 if (!s || (await $.clock.now()) - s.fetchedAt > OPEN_MS) void refresh($, false)
313 },
314 }
315}
316
317async function setBandHidden($: any, hidden: boolean) {
318 await update($, isBandHidden, () => hidden)
319 await syncStatus($)
320}
321
322/** La ligne d'état sous le prompt ne sert que de relais quand le bandeau est masqué. */
323async function syncStatus($: any) {
324 const s = await read($, snapshot)
325 const hidden = await read($, isBandHidden)
326 let text: string | undefined
327 if (s && hidden) {
328 const now = await $.clock.now()
329 const today = dayOf(now, s.tzOffset)
330 const active = s.blocks.find((b) => b.isActive && b.end > now) ?? null
331 const d = await read($, discussion)
332 text = statusText({ perHour: active?.costPerHour ?? null, discussion: d.live?.cost ?? null, today: sum(s.rows.filter((r) => r.date === today)).cost })
333 }
334 await quietly(() => $.ui.status(text))
335}
336
337/** Redessine pour faire avancer les secondes d'un relevé (toutes les 5 s) et les âges (à la minute). */
338async function tick($: any) {
339 ticks += 1
340 // Un relevé ou un détail de discussion en cours : l'indicateur du terminal avance.
341 const isBusy = (await read($, meta)).loading !== null || (await read($, discussion)).loading
342 if (isBusy || ticks % 12 === 0) await quietly(() => $.ui.invalidate('ui.render'))
343}
344
345// ─── Relevé ─────────────────────────────────────────────────────────────────
346
347/** Un relevé à la fois ; un autre demandé entre-temps attend celui en cours. */
348function refresh($: any, isAsked: boolean): Promise<void> {
349 if (inFlight) return inFlight
350 inFlight = (async () => {
351 try {
352 await load($, isAsked)
353 } catch (err) {
354 const text = `Relevé impossible : ${messageOf(err)}`
355 await update($, meta, (m) => ({ ...m, loading: null, startedAt: null, error: text }))
356 await say($, 'error', text)
357 } finally {
358 inFlight = null
359 }
360 })()
361 return inFlight
362}
363
364async function load($: any, isAsked: boolean) {
365 const startedAt = await $.clock.now()
366 const step = (loading: string) => update($, meta, (m) => ({ ...m, loading, startedAt: m.startedAt ?? startedAt }))
367 await step('Relevé ccusage : consommation par jour, projet et modèle…')
368 const stored = await read($, snapshot)
369 const previous = isSnapshot(stored) ? stored : null
370 const tz = await timezone($)
371 const base = ['--json', '--order', 'asc', ...(tz.name ? ['--timezone', tz.name] : [])]
372
373 // Le premier appel installe ccusage dans le cache de npx ; les suivants partent ensemble.
374 const daily = await ccusage($, ['claude', 'daily', '--instances', '--breakdown', ...base], false)
375 await step('Relevé ccusage : sessions et blocs de 5 h…')
376 const [sessions, blocks, version] = await Promise.all([
377 ccusage($, ['claude', 'session', ...base], daily.isOffline),
378 ccusage($, ['claude', 'blocks', '--breakdown', ...base], daily.isOffline),
379 ccusageVersion($),
380 ])
381 await step('Lecture du cache de /stats et des projets…')
382 const dir = await claudeDir($)
383 const home = await homeDir($)
384 const warnings: string[] = []
385 const name = await machineName($, dir)
386 const machine = name ?? LOCAL_LABEL
387 const activity = await readActivity($, dir)
388
389 // L'historique de ce poste : relevé gardé (même d'avant la 0.3.0) et fichier déjà publié.
390 const legacy = previous ? null : await Promise.resolve($.store.get(STORE_SNAPSHOT)).catch(() => null)
391 const history = ownHistory(machine, { previous, legacy, published: await readOwn($, dir, name) })
392 const kept = keepHistory(rowsOfDaily(daily.json, machine), history.rows, machine)
393 const ownSessions: SessionRow[] = keepSessions(sessionsOf(sessions.json, machine), history.sessions, machine)
394 .sort((a, b) => a.last - b.last)
395 .slice(-800)
396 let blockRows = blocksOf(blocks.json)
397 const keys = unique([...kept.rows.map((r) => r.project), ...ownSessions.map((s) => s.project)])
398 const ownProjects = await resolveProjects($, keys, previous?.projects ?? {}, dir, home)
399
400 await step('Partage entre postes : publication et lecture des autres postes…')
401 const own = { machine, tzOffset: tz.offset, rows: kept.rows, sessions: ownSessions, projects: ownProjects }
402 const others = await share($, dir, name, own, warnings)
403 const merged = mergeMachines(own, others)
404
405 let snap: Snapshot = {
406 schema: SCHEMA,
407 fetchedAt: await $.clock.now(),
408 tzOffset: tz.offset,
409 rows: merged.rows,
410 sessions: merged.sessions.slice(-1500),
411 blocks: blockRows.slice(-400),
412 activity,
413 projects: withLabels(merged.projects, await readLabels($, dir, warnings)),
414 unpriced: unpricedOf(daily.json, sessions.json),
415 isOffline: daily.isOffline || sessions.isOffline,
416 machine,
417 machines: merged.machines,
418 sessionless: merged.sessionless,
419 version,
420 keptDays: kept.kept,
421 warnings,
422 }
423 // Le store du plugin tient en 4 Mio : au-delà, les plus vieilles sessions et blocs cèdent.
424 while (JSON.stringify(snap).length > STORE_LIMIT && (snap.sessions.length > 100 || snap.blocks.length > 50)) {
425 blockRows = snap.blocks.slice(Math.floor(snap.blocks.length / 4))
426 snap = { ...snap, sessions: snap.sessions.slice(Math.floor(snap.sessions.length / 4)), blocks: blockRows }
427 }
428 // Encore trop gros : le store ne garde que ce poste ; les autres reviennent au relevé suivant.
429 const toStore = JSON.stringify(snap).length > STORE_LIMIT ? { ...snap, rows: snap.rows.filter((r) => r.machine === machine), sessions: snap.sessions.filter((s) => s.machine === machine), machines: [machine] } : snap
430
431 await update($, snapshot, () => snap)
432 await update($, meta, () => EMPTY_META)
433 await quietly(() => $.store.set(STORE_SNAPSHOT, toStore))
434 await syncStatus($)
435 if (isAsked) await say($, 'ok', `✓ Relevé à jour : ${fmtCost(sum(snap.rows).cost)} au total, ${daily.isOffline ? 'prix hors ligne' : 'prix en ligne'}.`)
436}
437
438// ─── Partage entre postes ───────────────────────────────────────────────────
439
440/** Le nom de ce poste, lu dans `claude-usage/.machine` ; null s'il n'est pas réglé. */
441async function machineName($: any, dir: string | null): Promise<string | null> {
442 if (!dir) return null
443 try {
444 const name = firstLine(await $.fs.read(`${dir}/${USAGE_DIR}/${MACHINE_FILE}`))
445 return name || null
446 } catch {
447 return null
448 }
449}
450
451/** Les noms d'affichage de `names.json` dans le dépôt partagé ; absent, aucun ; illisible, un avertissement. */
452async function readLabels($: any, dir: string | null, warnings: string[]): Promise<Record<string, string>> {
453 if (!dir) return {}
454 const file = `${dir}/${USAGE_DIR}/${LABELS_FILE}`
455 if (!(await Promise.resolve($.fs.exists(file)).catch(() => false))) return {}
456 try {
457 // Un fichier écrit par PowerShell commence par un BOM, que JSON.parse refuse.
458 const labels = parseLabels(JSON.parse(String(await $.fs.read(file)).replace(/^\uFEFF/, '')))
459 if (labels) return labels
460 warnings.push(`Noms des projets ignorés : ${USAGE_DIR}/${LABELS_FILE} n’est pas un objet { "dossier": "nom" }.`)
461 } catch (err) {
462 warnings.push(`Noms des projets illisibles (${USAGE_DIR}/${LABELS_FILE}) : ${messageOf(err)}`)
463 }
464 return {}
465}
466
467/** Le fichier que ce poste a déjà publié, s'il existe : son historique survit à un nouveau clone ou à une mise à jour. */
468async function readOwn($: any, dir: string | null, name: string | null): Promise<Published | null> {
469 if (!dir || !name) return null
470 try {
471 return parsePublished(JSON.parse(await $.fs.read(`${dir}/${USAGE_DIR}/machines/${machineFile(name)}`)))
472 } catch {
473 return null
474 }
475}
476
477async function git($: any, repo: string, args: string[]): Promise<Run> {
478 return $.process.run(['git', '-C', repo, ...args], { timeoutMs: 60_000, env: GIT_ENV })
479}
480
481/**
482 * Publie les usages de ce poste dans le dépôt partagé, puis lit ceux des autres. Chaque poste
483 * n'écrit que son fichier : `pull --rebase` ne peut pas entrer en conflit. Un échec de git
484 * (hors ligne, identifiants) devient un avertissement ; les fichiers déjà là sont lus quand même.
485 */
486async function share($: any, dir: string | null, name: string | null, own: Parameters<typeof publishedOf>[0], warnings: string[]): Promise<Published[]> {
487 if (!dir) return []
488 const repo = `${dir}/${USAGE_DIR}`
489 if (!(await Promise.resolve($.fs.exists(`${repo}/.git`)).catch(() => false))) {
490 warnings.push(`Partage entre postes désactivé : pas de dépôt ${USAGE_DIR} dans le dossier de Claude Code (/setup:install le clone).`)
491 return []
492 }
493 if (!name) warnings.push(`Ce poste n’a pas de nom : ses usages ne sont pas publiés (/setup:install, ou une ligne dans ${USAGE_DIR}/${MACHINE_FILE}).`)
494 // Plusieurs sessions de Claude Code font leur relevé en même temps sur ce poste : git refuse
495 // alors la seconde (`index.lock`), ou son `pull` trouve `origin/main` déjà avancé par l'autre
496 // (`cannot lock ref`). On réessaie un peu plus tard ; si ça bloque encore, l'autre session
497 // publie les mêmes données, sauf si le verrou d'index traîne depuis longtemps (git planté).
498 for (let attempt = 1; ; attempt++) {
499 try {
500 if (name) await publish($, repo, name, own)
501 else {
502 const pull = await git($, repo, ['pull', '--ff-only'])
503 if (pull.exitCode !== 0) throw new Error(firstLine(pull.stderr) || `git pull : code ${pull.exitCode}`)
504 }
505 break
506 } catch (err) {
507 const isRefRace = /cannot lock ref/i.test(messageOf(err))
508 const isLocked = isRefRace || /\.lock\b|another git process/i.test(messageOf(err))
509 if (isLocked && attempt < 3) {
510 await $.clock.sleep(2_000 * attempt)
511 continue
512 }
513 if (!isLocked) warnings.push(`Partage entre postes : ${messageOf(err)}`)
514 else if (!isRefRace && (await isStaleLock($, repo))) warnings.push(`Partage entre postes : un verrou git traîne depuis plus de 10 min (${USAGE_DIR}/.git/index.lock, git a dû s’arrêter en route) ; le supprimer débloque la publication.`)
515 break
516 }
517 }
518 return readOthers($, repo, name, warnings)
519}
520
521/** Le verrou d'index de git date de plus de 10 min : aucun git ne tourne plus, il est abandonné. */
522async function isStaleLock($: any, repo: string): Promise<boolean> {
523 try {
524 const entries: { name: string; mtimeMs: number }[] = await $.fs.list(`${repo}/.git`)
525 const lock = entries.find((e) => e.name === 'index.lock')
526 return !!lock && (await $.clock.now()) - lock.mtimeMs > 10 * 60_000
527 } catch {
528 return false
529 }
530}
531
532async function publish($: any, repo: string, name: string, own: Parameters<typeof publishedOf>[0]) {
533 const file = `machines/${machineFile(name)}`
534 const body = publishedOf(own)
535 // Rien de neuf depuis la dernière publication : ni écriture, ni commit (l'heure seule ne compte pas).
536 const before = await Promise.resolve($.fs.read(`${repo}/${file}`)).catch(() => null)
537 let isSame = false
538 try {
539 const { updatedAt: _, ...old } = JSON.parse(before ?? 'null') ?? {}
540 isSame = JSON.stringify(old) === JSON.stringify(body)
541 } catch {
542 // Fichier absent ou abîmé : il est réécrit.
543 }
544 if (!isSame) await $.fs.write(`${repo}/${file}`, JSON.stringify({ ...body, updatedAt: await $.clock.now() }))
545 // Pas commité, cette fois ou une précédente (commit raté) : on (re)tente, sinon le rebase refuserait.
546 const pending = await git($, repo, ['status', '--porcelain', '--', file])
547 if (pending.exitCode === 0 && pending.stdout.trim()) {
548 const add = await git($, repo, ['add', '--', file])
549 if (add.exitCode !== 0) throw new Error(`git add : ${firstLine(add.stderr) || `code ${add.exitCode}`}`)
550 const commit = await git($, repo, ['commit', '-q', '-m', `usage: ${name}`, '--', file])
551 if (commit.exitCode !== 0) throw new Error(`git commit : ${firstLine(commit.stderr) || firstLine(commit.stdout) || `code ${commit.exitCode}`}`)
552 }
553 const pull = await git($, repo, ['pull', '--rebase', '-q'])
554 if (pull.exitCode !== 0) {
555 // Un rebase arrêté en route bloquerait tous les relevés suivants : on revient à l'état d'avant.
556 await git($, repo, ['rebase', '--abort']).catch(() => undefined)
557 throw new Error(`git pull : ${firstLine(pull.stderr) || `code ${pull.exitCode}`}`)
558 }
559 // Sans branche suivie (`@{u}` inconnu), on pousse quand même.
560 const ahead = await git($, repo, ['rev-list', '--count', '@{u}..HEAD'])
561 if (ahead.exitCode !== 0 || Number(ahead.stdout.trim()) > 0) {
562 const push = await git($, repo, ['push', '-q'])
563 if (push.exitCode !== 0) throw new Error(`git push : ${firstLine(push.stderr) || `code ${push.exitCode}`}`)
564 }
565}
566
567/** Les fichiers publiés des autres postes (`machines/*.json`), sauf celui de ce poste. */
568async function readOthers($: any, repo: string, name: string | null, warnings: string[]): Promise<Published[]> {
569 const own = name ? machineFile(name) : null
570 let entries: { name: string; kind: string }[] = []
571 try {
572 entries = await $.fs.list(`${repo}/machines`)
573 } catch {
574 return []
575 }
576 const out: Published[] = []
577 for (const e of entries) {
578 if (e.kind !== 'file' || !e.name.endsWith('.json') || e.name === own) continue
579 try {
580 const p = parsePublished(JSON.parse(await $.fs.read(`${repo}/machines/${e.name}`)))
581 if (p) out.push(p)
582 else warnings.push(`Usages d’un autre poste ignorés : machines/${e.name} n’a pas le format attendu.`)
583 } catch (err) {
584 warnings.push(`Usages d’un autre poste illisibles (machines/${e.name}) : ${messageOf(err)}`)
585 }
586 }
587 return out.sort((a, b) => a.machine.localeCompare(b.machine))
588}
589
590// ─── Discussion ouverte ─────────────────────────────────────────────────────
591
592/** La discussion ouverte d'après le moteur : son identifiant, son coût (comme /cost), son contexte, ses tours. */
593async function readLive($: any) {
594 const id: string = await $.session.id()
595 const u = await $.session.usage()
596 const turns = await Promise.resolve($.session.turns()).catch(() => 0)
597 const model = await Promise.resolve($.session.model()).catch(() => null)
598 const live: NonNullable<Discussion['live']> = {
599 cost: typeof u?.cost?.usd === 'number' ? u.cost.usd : null,
600 contextPercent: typeof u?.context?.percent === 'number' ? u.context.percent : null,
601 contextTokens: typeof u?.context?.tokens === 'number' ? u.context.tokens : null,
602 window: typeof u?.context?.window === 'number' ? u.context.window : null,
603 turns: typeof turns === 'number' ? turns : 0,
604 model: typeof model === 'string' ? model : null,
605 startedAt: typeof u?.startedAt === 'number' ? u.startedAt : null,
606 at: await $.clock.now(),
607 }
608 // Une autre discussion (après /clear) : son détail et ses tours repartent de zéro.
609 await update($, discussion, (d) => (d.id === id ? { ...d, live } : { ...EMPTY_DISCUSSION, id, live }))
610}
611
612/** Le détail de la discussion ouverte (`ccusage claude session --id`) : quelques secondes, un à la fois. */
613function refreshDiscussion($: any): Promise<void> {
614 if (detailInFlight) return detailInFlight
615 detailInFlight = (async () => {
616 try {
617 await update($, discussion, (d) => ({ ...d, loading: true, error: null }))
618 await quietly(() => readLive($))
619 const id: string = await $.session.id()
620 const res = await npx($, ['-y', 'ccusage@latest', 'claude', 'session', '--id', id, '--json'])
621 if (res.exitCode !== 0) throw new Error(firstLine(res.stderr) || firstLine(res.stdout) || `code ${res.exitCode}`)
622 // `null` : ccusage ne trouve pas encore la discussion (aucun appel écrit dans sa transcription).
623 const parsed = res.stdout.trim() === 'null' ? { cost: 0, tokens: 0, entries: [] } : discussionOf(parseJson(res.stdout))
624 const fetchedAt = await $.clock.now()
625 await update($, discussion, (d) => ({ ...(d.id === id ? d : { ...EMPTY_DISCUSSION, id }), detail: { fetchedAt, ...parsed }, loading: false, error: null }))
626 } catch (err) {
627 await update($, discussion, (d) => ({ ...d, loading: false, error: `Détail de la discussion impossible : ${messageOf(err)}` }))
628 } finally {
629 detailInFlight = null
630 }
631 })()
632 return detailInFlight
633}
634
635/** Lance npx, en essayant les lanceurs connus de Windows ; ne passe au suivant que si le précédent ne démarre pas. */
636async function npx($: any, args: string[]): Promise<Run> {
637 const tries = launcher ? [launcher] : [['npx'], ['npx.cmd'], ['cmd.exe', '/d', '/s', '/c', 'npx']]
638 // L'erreur citée est celle de `npx` lui-même : les lanceurs suivants n'existent que sous Windows.
639 let first: unknown = null
640 for (const l of tries) {
641 try {
642 const res: Run = await $.process.run([...l, ...args], {
643 timeoutMs: 240_000,
644 env: { NO_COLOR: '1', FORCE_COLOR: '0', npm_config_update_notifier: 'false', npm_config_fund: 'false' },
645 })
646 launcher = l
647 return res
648 } catch (err) {
649 first ??= err
650 // Lancé mais trop lent : un autre lanceur ne ferait pas mieux.
651 if (/time|still running|délai/i.test(messageOf(err))) throw new Error(`ccusage n'a pas répondu à temps (${messageOf(err)})`)
652 }
653 }
654 throw new Error(`npx introuvable : Node.js est-il installé ? (${messageOf(first)})`)
655}
656
657/** Une commande ccusage en JSON ; en cas d'échec avec les prix en ligne, un second essai avec les prix embarqués. */
658async function ccusage($: any, args: string[], isOffline: boolean): Promise<{ json: Json; isOffline: boolean }> {
659 const once = async (offline: boolean) => {
660 const res = await npx($, ['-y', 'ccusage@latest', ...args, ...(offline ? ['--offline'] : [])])
661 if (res.exitCode !== 0) throw new Error(`ccusage ${args.slice(0, 2).join(' ')} : ${firstLine(res.stderr) || firstLine(res.stdout) || `code ${res.exitCode}`}`)
662 // Sans réseau, ccusage retombe seul sur ses prix embarqués et le dit sur stderr.
663 return { json: parseJson(res.stdout), isOffline: offline || /embedded pricing|failed to fetch/i.test(res.stderr) }
664 }
665 if (isOffline) return once(true)
666 try {
667 return await once(false)
668 } catch {
669 return once(true)
670 }
671}
672
673async function ccusageVersion($: any): Promise<string | null> {
674 try {
675 const res = await npx($, ['-y', 'ccusage@latest', '--version'])
676 return res.stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null
677 } catch {
678 return null
679 }
680}
681
682/** Le fuseau de la machine (par Node) : ccusage groupe les jours avec, le mod calcule « aujourd'hui » avec. */
683async function timezone($: any): Promise<{ offset: number; name: string | null }> {
684 try {
685 const res: Run = await $.process.run(['node', '-p', 'JSON.stringify([new Date().getTimezoneOffset(), Intl.DateTimeFormat().resolvedOptions().timeZone])'], { timeoutMs: 15_000 })
686 const [offset, name] = JSON.parse(res.stdout.trim()) as [unknown, unknown]
687 if (typeof offset === 'number') return { offset, name: typeof name === 'string' && /^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)*$/.test(name) ? name : null }
688 } catch {
689 // Pas de Node à part : le fuseau de l'environnement du mod.
690 }
691 return { offset: new Date().getTimezoneOffset(), name: null }
692}
693
694async function homeDir($: any): Promise<string | null> {
695 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? null
696}
697
698/** Le dossier de Claude Code : `CLAUDE_CONFIG_DIR` (le premier s'il y en a plusieurs), sinon `~/.claude`. */
699async function claudeDir($: any): Promise<string | null> {
700 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
701 if (custom) return custom.split(',')[0]!.trim()
702 const home = await homeDir($)
703 return home ? `${home}/.claude` : null
704}
705
706async function readActivity($: any, dir: string | null) {
707 if (!dir) return null
708 try {
709 return activityOf(JSON.parse(await $.fs.read(`${dir}/stats-cache.json`)))
710 } catch {
711 return null
712 }
713}
714
715// Lit le début (256 Kio) des plus petites transcriptions de chaque projet et rend `{ clé: dossier }` :
716// le premier `"cwd"` qui donne la clé, à défaut le premier tout court (comme `cwdOf`).
717// Par Node : une transcription dépasse souvent les 4 Mio que `$.fs.read` accepte.
718const CWD_SCRIPT = String.raw`const fs=require('fs'),p=require('path');const [dir,...keys]=process.argv.slice(1);const out={};for(const k of keys){try{const d=p.join(dir,'projects',k);const fl=fs.readdirSync(d).filter(f=>f.endsWith('.jsonl')).map(f=>({f,s:fs.statSync(p.join(d,f)).size})).filter(x=>x.s>0).sort((a,b)=>a.s-b.s).slice(0,5);let first=null,hit=null;for(const {f} of fl){const fd=fs.openSync(p.join(d,f),'r');const b=Buffer.alloc(262144);const n=fs.readSync(fd,b,0,b.length,0);fs.closeSync(fd);for(const m of b.toString('utf8',0,n).matchAll(/"cwd":"((?:[^"\\]|\\.)*)"/g)){let c;try{c=JSON.parse('"'+m[1]+'"')}catch{continue}if(c.replace(/[^a-zA-Z0-9]/g,'-')===k){hit=c;break}if(first===null)first=c}if(hit!==null)break}const r=hit??first;if(r!==null)out[k]=r}catch{}}console.log(JSON.stringify(out))`
719
720/**
721 * Le dossier de chaque projet de ce poste, lu une fois dans ses transcriptions puis gardé : la clé
722 * ccusage seule ne dit pas où étaient les tirets. Un dossier gardé qui ne donne pas sa clé
723 * (`isStale`) est relu ; s'il n'est plus lisible, l'ancien reste. Seules les clés de ce poste
724 * sont rendues : les projets des autres postes reviennent de leurs fichiers publiés. Un seul
725 * processus Node pour tous les projets à lire ; s'il échoue, une lecture directe des petites
726 * transcriptions.
727 */
728async function resolveProjects($: any, keys: readonly string[], known: Record<string, ProjectInfo>, dir: string | null, home: string | null): Promise<Record<string, ProjectInfo>> {
729 const out: Record<string, ProjectInfo> = {}
730 for (const k of keys) if (known[k] && !isStale(k, known[k]!)) out[k] = known[k]!
731 const todo = keys.filter((k) => !k.startsWith('@') && !out[k]).slice(0, 120)
732 let found: Record<string, unknown> = {}
733 if (dir && todo.length) {
734 try {
735 const res: Run = await $.process.run(['node', '-e', CWD_SCRIPT, dir, ...todo], { timeoutMs: 60_000 })
736 if (res.exitCode === 0) found = JSON.parse(res.stdout.trim() || '{}')
737 } catch {
738 // Pas de Node à part : la lecture directe ci-dessous.
739 }
740 }
741 for (const key of todo) {
742 let path: string | null = typeof found[key] === 'string' ? (found[key] as string) : null
743 if (dir && !path) {
744 try {
745 const entries: { name: string; kind: string; size: number }[] = await $.fs.list(`${dir}/projects/${key}`)
746 const files = entries
747 .filter((x) => x.kind === 'file' && x.name.endsWith('.jsonl') && x.size > 0 && x.size < 3_500_000)
748 .sort((a, b) => a.size - b.size)
749 .slice(0, 5)
750 let first: string | null = null
751 for (const f of files) {
752 const cwd = cwdOf(await $.fs.read(`${dir}/projects/${key}/${f.name}`), key)
753 if (cwd && projectKey(cwd) === key) {
754 path = cwd
755 break
756 }
757 first ??= cwd
758 }
759 path ??= first
760 } catch {
761 // Dossier effacé ou illisible : le nom vient de la clé.
762 }
763 }
764 out[key] = path ? { name: nameOfPath(path, home), path } : (known[key] ?? { name: '', path: null })
765 }
766 // Au-delà des 120 lus par relevé : le dossier gardé, en attendant le relevé suivant.
767 for (const k of keys) if (!out[k] && known[k]) out[k] = known[k]!
768 return out
769}
770
771// ─── Petits outils ──────────────────────────────────────────────────────────
772
773const isSnapshot = (value: unknown): value is Snapshot =>
774 typeof value === 'object' && value !== null && (value as Snapshot).schema === SCHEMA && Array.isArray((value as Snapshot).rows) && typeof (value as Snapshot).fetchedAt === 'number' && Array.isArray((value as Snapshot).sessions)
775
776// Un mot dans la zone d'activité, doublé d'un toast ; les confirmations s'effacent seules,
777// les erreurs restent jusqu'au « x ».
778async function say($: any, tone: 'ok' | 'error' | 'info', text: string) {
779 await update($, notice, () => ({ text, tone }))
780 await quietly(() => $.ui.toast(text))
781 if (tone !== 'error') $.clock.after(8000, () => void update($, notice, (x) => (x && x.text === text ? null : x)))
782}
783hooks/model.ts 986 lines1// Données pures de stats-board : palette, lecture des sorties de ccusage et du cache de
2// /stats, fusion avec les usages des autres postes, filtres, agrégats et libellés en français.
3// Aucun appel au moteur ici : tout ce qui touche `$` vit dans register.tsx.
4
5import type { Activity, BlockRow, DiscussionEntry, Grain, Machine, Metric, Period, ProjectInfo, Published, SessionRow, Ui, UsageRow } from '../types'
6
7// Couleurs brutes (hex) mi-tons, copiées de git-board (hooks/model.ts) : lisibles en thème
8// clair comme sombre, sur le terminal comme sur le desktop.
9export const C = {
10 violet: '#9B7BEA',
11 lilac: '#B79BF5',
12 blue: '#4A9EE8',
13 cyan: '#2EB8C9',
14 teal: '#3FB68B',
15 tangerine: '#E8913A',
16 coral: '#EF7D57',
17 amber: '#D9A520',
18 green: '#4CB782',
19 red: '#E5534B',
20 pink: '#E06C9F',
21 magenta: '#C36BD9',
22 indigo: '#7C83F2',
23 sky: '#56B4E9',
24 sand: '#C9A66B',
25 slate: '#7F8EA3',
26 gray: '#8B8B93',
27 /** Texte foncé posé sur un aplat de couleur. */
28 ink: '#17171C',
29} as const
30
31// L'app desktop fond les espaces ordinaires : les écarts dans un texte sont insécables.
32// NB contient une espace insécable U+00A0 littérale (invisible) : ne pas la remplacer.
33export const NB = ' '
34export const SEP = `${NB}${NB}·${NB}${NB}`
35
36/** Couleur d'identité du mod (bleu ciel, comme les stats du desktop) ; l'ambre dit l'argent. */
37export const BRAND = C.sky
38export const MONEY = C.amber
39
40/** Format du relevé (`Snapshot.schema`) : 3 depuis que chaque ligne dit son poste. */
41export const SCHEMA = 3
42/** Format du fichier qu'un poste publie dans `claude-usage` (`Published.schema`). */
43export const PUBLISH_SCHEMA = 1
44/** Le nom de ce poste quand aucun n'est réglé : ses usages ne sont alors pas publiés. */
45export const LOCAL_LABEL = 'Ce poste'
46/** Les brouillons du desktop (`scratch-workspaces`), réunis en un seul projet. */
47export const DRAFTS = '@brouillons'
48/** Lignes que ccusage n'a rattachées à aucun projet. */
49export const UNKNOWN = '@inconnu'
50export const UNKNOWN_MODEL = '@modele-inconnu'
51
52export const DAY_MS = 86_400_000
53
54type Json = Record<string, any>
55
56const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
57const str = (v: unknown): string => (typeof v === 'string' ? v : '')
58const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
59export const unique = <T,>(list: readonly T[]): T[] => [...new Set(list)]
60
61// ─── Lecture des sorties de ccusage ─────────────────────────────────────────
62
63/** Le JSON d'une sortie de ccusage : ce qui précède la première accolade est ignoré. */
64export function parseJson(out: string): Json {
65 const start = out.indexOf('{')
66 const end = out.lastIndexOf('}')
67 if (start < 0 || end < start) throw new Error('sortie illisible (pas de JSON)')
68 return JSON.parse(out.slice(start, end + 1))
69}
70
71const isDate = (date: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(date)
72
73/** Les lignes d'un jour de ccusage, une par modèle. `date` en v20, `period` dans le rapport tous agents. */
74function rowsOfDay(day: Json, project: string, machine: string): UsageRow[] {
75 const date = str(day?.date) || str(day?.period)
76 if (!isDate(date)) return []
77 const parts: Json[] = Array.isArray(day.modelBreakdowns) ? day.modelBreakdowns : []
78 const rows: UsageRow[] = parts.length
79 ? parts.map((m) => ({
80 date,
81 project,
82 machine,
83 model: str(m.modelName) || UNKNOWN_MODEL,
84 input: num(m.inputTokens),
85 output: num(m.outputTokens),
86 cacheWrite: num(m.cacheCreationTokens),
87 cacheRead: num(m.cacheReadTokens),
88 cost: num(m.cost),
89 }))
90 : [
91 {
92 date,
93 project,
94 machine,
95 model: UNKNOWN_MODEL,
96 input: num(day.inputTokens),
97 output: num(day.outputTokens),
98 cacheWrite: num(day.cacheCreationTokens),
99 cacheRead: num(day.cacheReadTokens),
100 cost: num(day.totalCost),
101 },
102 ]
103 // `<synthetic>` : les messages que Claude Code fabrique lui-même, sans token.
104 return rows.filter((r) => r.model !== '<synthetic>' && r.input + r.output + r.cacheWrite + r.cacheRead + r.cost > 0)
105}
106
107/** `ccusage claude daily --json --instances` : `{ projects: { clé: jours[] }, totals }` ; sans projets, `{ daily }`. */
108export function rowsOfDaily(json: Json, machine: string): UsageRow[] {
109 if (json?.projects && typeof json.projects === 'object') {
110 return Object.entries(json.projects as Record<string, unknown>).flatMap(([key, days]) =>
111 Array.isArray(days) ? days.flatMap((d) => rowsOfDay(d, key === 'unknown' ? UNKNOWN : key, machine)) : [],
112 )
113 }
114 return (Array.isArray(json?.daily) ? json.daily : []).flatMap((d: Json) => rowsOfDay(d, UNKNOWN, machine))
115}
116
117/** `ccusage claude session --json` : `{ sessions: [...] }`. */
118export function sessionsOf(json: Json, machine: string): SessionRow[] {
119 const list: Json[] = Array.isArray(json?.sessions) ? json.sessions : Array.isArray(json?.session) ? json.session : []
120 const out: SessionRow[] = []
121 for (const s of list) {
122 const last = Date.parse(str(s.lastActivity) || str(s.metadata?.lastActivity))
123 const first = Date.parse(str(s.firstActivity))
124 const id = str(s.sessionId) || str(s.period)
125 if (!id || !Number.isFinite(last)) continue
126 // Un sous-agent de workflow écrit sous `<projet>\<session>\subagents\…` : il compte pour son
127 // projet et se rattache à la discussion qui l'a lancé.
128 const path = str(s.projectPath).split(/[\\/]/)
129 out.push({
130 id,
131 machine,
132 project: path[0] || UNKNOWN,
133 parent: path[2] === 'subagents' && path[1] ? path[1] : null,
134 first: Number.isFinite(first) ? first : last,
135 last,
136 cost: num(s.totalCost),
137 tokens: num(s.totalTokens),
138 output: num(s.outputTokens),
139 models: strings(s.modelsUsed).filter((m) => m !== '<synthetic>'),
140 })
141 }
142 return out
143}
144
145/** `ccusage claude blocks --json` : les blocs de 5 h, sans les creux (`isGap`). */
146export function blocksOf(json: Json): BlockRow[] {
147 const list: Json[] = Array.isArray(json?.blocks) ? json.blocks : []
148 const out: BlockRow[] = []
149 for (const b of list) {
150 if (b.isGap === true) continue
151 const start = Date.parse(str(b.startTime))
152 if (!Number.isFinite(start)) continue
153 const end = Date.parse(str(b.endTime))
154 const lastAt = Date.parse(str(b.actualEndTime))
155 const tc = b.tokenCounts ?? {}
156 const tokens = num(b.totalTokens) || num(tc.inputTokens) + num(tc.outputTokens) + num(tc.cacheCreationInputTokens) + num(tc.cacheReadInputTokens)
157 out.push({
158 start,
159 end: Number.isFinite(end) ? end : start + 5 * 3_600_000,
160 lastAt: Number.isFinite(lastAt) ? lastAt : start,
161 isActive: b.isActive === true,
162 cost: num(b.costUSD),
163 tokens,
164 models: strings(b.models).filter((m) => m !== '<synthetic>'),
165 costPerHour: b.burnRate ? num(b.burnRate.costPerHour) : null,
166 projectedCost: b.projection ? num(b.projection.totalCost) : null,
167 remainingMinutes: b.projection ? num(b.projection.remainingMinutes) : null,
168 })
169 }
170 return out
171}
172
173/** Les modèles sans prix : `totals.unpricedModels` et les `missingPricing` du détail par modèle. */
174export function unpricedOf(...jsons: (Json | null)[]): string[] {
175 const found = new Set<string>()
176 const scanDays = (days: unknown) => {
177 if (!Array.isArray(days)) return
178 for (const d of days) for (const m of Array.isArray(d?.modelBreakdowns) ? d.modelBreakdowns : []) if (m?.missingPricing === true && typeof m.modelName === 'string') found.add(m.modelName)
179 }
180 for (const json of jsons) {
181 if (!json) continue
182 strings(json.totals?.unpricedModels).forEach((m) => found.add(m))
183 scanDays(json.daily)
184 scanDays(json.sessions)
185 if (json.projects && typeof json.projects === 'object') Object.values(json.projects as Record<string, unknown>).forEach(scanDays)
186 }
187 found.delete('<synthetic>')
188 return [...found].sort()
189}
190
191/** Le cache de /stats de Claude Code (`~/.claude/stats-cache.json`). */
192export function activityOf(json: Json): Activity | null {
193 if (!json || typeof json !== 'object' || !Array.isArray(json.dailyActivity)) return null
194 const days = (json.dailyActivity as Json[])
195 .map((d) => ({ date: str(d.date), messages: num(d.messageCount), sessions: num(d.sessionCount), tools: num(d.toolCallCount) }))
196 .filter((d) => isDate(d.date))
197 const hours = Array.from({ length: 24 }, (_, h) => num(json.hourCounts?.[String(h)]))
198 const l = json.longestSession
199 return {
200 through: str(json.lastComputedDate) || null,
201 days,
202 hours,
203 sessions: num(json.totalSessions),
204 messages: num(json.totalMessages),
205 longest: l && typeof l === 'object' ? { ms: num(l.duration), messages: num(l.messageCount), at: str(l.timestamp) } : null,
206 since: str(json.firstSessionDate) || null,
207 }
208}
209
210/**
211 * Garde les jours que Claude Code a effacés (ses journaux ne vivent que `cleanupPeriodDays`
212 * jours) : les lignes de ce poste dans le relevé précédent plus anciennes que le premier jour
213 * du nouveau. Celles des autres postes reviennent par leurs fichiers publiés.
214 */
215export function keepHistory(fresh: UsageRow[], previous: readonly UsageRow[] | undefined, machine: string): { rows: UsageRow[]; kept: number } {
216 const mine = (previous ?? []).filter((r) => r.machine === machine)
217 if (!mine.length) return { rows: fresh, kept: 0 }
218 // Rien de frais (poste resté inactif plus longtemps que les journaux) : tout l'historique reste.
219 const first = fresh.length ? fresh.reduce((m, r) => (r.date < m ? r.date : m), fresh[0]!.date) : '9999-12-31'
220 const old = mine.filter((r) => r.date < first)
221 return { rows: [...old, ...fresh], kept: unique(old.map((r) => r.date)).length }
222}
223
224/** Les sessions de ce poste dans le relevé précédent que le nouveau n'a plus, parce que leurs journaux ont été effacés. */
225export function keepSessions(fresh: SessionRow[], previous: readonly SessionRow[] | undefined, machine: string): SessionRow[] {
226 const mine = (previous ?? []).filter((s) => s.machine === machine)
227 if (!mine.length) return fresh
228 const ids = new Set(fresh.map((s) => s.id))
229 const first = fresh.length ? Math.min(...fresh.map((s) => s.first)) : Infinity
230 return [...mine.filter((s) => !ids.has(s.id) && s.last < first), ...fresh]
231}
232
233/**
234 * L'historique connu de ce poste, sous son nom actuel, pour `keepHistory` et `keepSessions` :
235 * le relevé gardé (ses lignes à ce nom ou encore « Ce poste »), un relevé gardé de l'ancien
236 * format (schéma 2 : tout sauf l'ancien PC) et le fichier que ce poste a déjà publié. Ainsi ni
237 * une mise à jour du mod, ni un premier nommage, ni un nouveau clone ne perdent les jours que
238 * Claude Code a effacés. Une ligne présente deux fois (même jour, projet et modèle) compte une fois.
239 */
240export function ownHistory(machine: string, sources: { previous?: { rows: readonly UsageRow[]; sessions: readonly SessionRow[] } | null; legacy?: Json | null; published?: Published | null }): { rows: UsageRow[]; sessions: SessionRow[] } {
241 const rows = new Map<string, UsageRow>()
242 const sessions = new Map<string, SessionRow>()
243 const addRow = (r: UsageRow) => {
244 const key = `${r.date}|${r.project}|${r.model}`
245 if (!rows.has(key)) rows.set(key, { ...r, machine })
246 }
247 const addSession = (s: SessionRow) => {
248 if (!sessions.has(s.id)) sessions.set(s.id, { ...s, machine })
249 }
250 const isMine = (m: string) => m === machine || m === LOCAL_LABEL
251 for (const r of sources.previous?.rows ?? []) if (isMine(r.machine)) addRow(r)
252 for (const s of sources.previous?.sessions ?? []) if (isMine(s.machine)) addSession(s)
253 const legacy = sources.legacy
254 if (legacy && legacy.schema === 2 && Array.isArray(legacy.rows)) {
255 for (const r of legacy.rows as Json[]) if (r && !r.isPast && isDate(str(r.date)) && typeof r.project === 'string') addRow({ date: r.date, project: r.project, machine, model: str(r.model) || UNKNOWN_MODEL, input: num(r.input), output: num(r.output), cacheWrite: num(r.cacheWrite), cacheRead: num(r.cacheRead), cost: num(r.cost) })
256 for (const s of Array.isArray(legacy.sessions) ? (legacy.sessions as Json[]) : []) if (s && typeof s.id === 'string' && Number.isFinite(s.last)) addSession({ id: s.id, machine, project: str(s.project) || UNKNOWN, parent: typeof s.parent === 'string' ? s.parent : null, first: num(s.first) || num(s.last), last: num(s.last), cost: num(s.cost), tokens: num(s.tokens), output: num(s.output), models: strings(s.models) })
257 }
258 for (const r of sources.published?.rows ?? []) addRow(r)
259 for (const s of sources.published?.sessions ?? []) addSession(s)
260 return { rows: [...rows.values()], sessions: [...sessions.values()] }
261}
262
263// ─── Postes ─────────────────────────────────────────────────────────────────
264
265/** Le fichier d'un poste dans le dépôt partagé : `Mac.json`, `Portable.json`… */
266export const machineFile = (machine: string): string => `${machine.replace(/[^A-Za-z0-9_-]/g, '_')}.json`
267
268/** Ce que ce poste publie : ses lignes, ses sessions, et les projets qu'elles citent (sans `updatedAt`). */
269export function publishedOf(o: { machine: string; tzOffset: number; rows: readonly UsageRow[]; sessions: readonly SessionRow[]; projects: Record<string, ProjectInfo> }): Omit<Published, 'updatedAt'> {
270 const keys = new Set([...o.rows.map((r) => r.project), ...o.sessions.map((s) => s.project)])
271 const projects: Record<string, ProjectInfo> = {}
272 for (const k of [...keys].sort()) if (o.projects[k]) projects[k] = { name: o.projects[k]!.name, path: o.projects[k]!.path }
273 return { schema: PUBLISH_SCHEMA, machine: o.machine, tzOffset: o.tzOffset, rows: [...o.rows], sessions: [...o.sessions], projects }
274}
275
276/** Le fichier publié d'un autre poste, ou null s'il n'a pas la bonne forme. Ses lignes prennent son nom. */
277export function parsePublished(json: Json): Published | null {
278 if (!json || json.schema !== PUBLISH_SCHEMA || typeof json.machine !== 'string' || !json.machine || !Array.isArray(json.rows)) return null
279 const machine: string = json.machine
280 const rows = (json.rows as Json[])
281 .filter((r) => r && isDate(str(r.date)) && typeof r.project === 'string' && typeof r.model === 'string')
282 .map((r): UsageRow => ({ date: r.date, project: r.project, machine, model: r.model, input: num(r.input), output: num(r.output), cacheWrite: num(r.cacheWrite), cacheRead: num(r.cacheRead), cost: num(r.cost) }))
283 const sessions = (Array.isArray(json.sessions) ? (json.sessions as Json[]) : [])
284 .filter((s) => s && typeof s.id === 'string' && typeof s.project === 'string' && Number.isFinite(s.last))
285 .map((s): SessionRow => ({ id: s.id, machine, project: s.project, parent: typeof s.parent === 'string' ? s.parent : null, first: num(s.first) || num(s.last), last: num(s.last), cost: num(s.cost), tokens: num(s.tokens), output: num(s.output), models: strings(s.models) }))
286 const projects: Record<string, ProjectInfo> = {}
287 for (const [k, v] of Object.entries((json.projects ?? {}) as Record<string, Json>)) {
288 if (v && typeof v === 'object') projects[k] = { name: str(v.name), path: typeof v.path === 'string' ? v.path : null }
289 }
290 return { schema: PUBLISH_SCHEMA, machine, updatedAt: num(json.updatedAt), tzOffset: num(json.tzOffset), rows, sessions, projects }
291}
292
293/** Le dossier d'une session lancée par SSH depuis le desktop : `ssh-<id de la session>`. */
294const SSH_KEY = /^ssh-(.+)$/
295
296/**
297 * Ajoute aux usages de ce poste ceux des autres. Un projet d'un autre poste rejoint celui qui porte
298 * le même nom de dossier (`C:\dev\PolePredict` et `~/dev/PolePredict`) : il prend la clé déjà connue,
299 * celle de ce poste d'abord ; un worktree compte sous le nom de son dépôt. Les brouillons du desktop
300 * gardent leur clé : ils se regroupent seuls. Une session SSH du desktop, que le poste qui l'a
301 * lancée garde sous `ssh-<id>`, ne compte qu'une fois si le poste où elle a tourné la publie.
302 */
303export function mergeMachines(
304 local: { machine: string; rows: readonly UsageRow[]; sessions: readonly SessionRow[]; projects: Record<string, ProjectInfo> },
305 others: readonly Published[],
306): { rows: UsageRow[]; sessions: SessionRow[]; projects: Record<string, ProjectInfo>; machines: string[]; sessionless: string[] } {
307 const projects: Record<string, ProjectInfo> = { ...local.projects }
308 const sessionless: string[] = []
309 const byName = new Map<string, string>()
310 const nameOf = (key: string, info: ProjectInfo | undefined): string | null => {
311 const name = info?.name?.toLowerCase()
312 return name && !key.startsWith('@') && !isDraft(key, info) ? name : null
313 }
314 // Les sessions qui ont tourné sur chaque poste : une copie `ssh-<id>` d'une session d'un autre poste est écartée.
315 const ranOn = new Map<string, string>()
316 for (const p of [{ machine: local.machine, sessions: local.sessions }, ...others]) for (const x of p.sessions) if (!SSH_KEY.test(x.project)) ranOn.set(x.id, p.machine)
317 const isCopy = (project: string, machine: string): boolean => {
318 const host = ranOn.get(project.match(SSH_KEY)?.[1] ?? '')
319 return !!host && host !== machine
320 }
321 const ownRows = local.rows.filter((r) => !isCopy(r.project, local.machine))
322 const ownSessions = local.sessions.filter((x) => !isCopy(x.project, local.machine))
323 for (const key of unique([...ownRows.map((r) => r.project), ...ownSessions.map((s) => s.project)])) {
324 const root = rootOf(key, local.projects)
325 if (root.info && !projects[root.key]) projects[root.key] = root.info
326 const name = nameOf(root.key, root.info)
327 if (name && !byName.has(name)) byName.set(name, root.key)
328 }
329 const rows = [...ownRows]
330 const sessions = [...ownSessions]
331 const machines = [local.machine]
332 for (const o of others) {
333 if (o.machine === local.machine) continue
334 if (!machines.includes(o.machine)) machines.push(o.machine)
335 if (!o.sessions.length && !sessionless.includes(o.machine)) sessionless.push(o.machine)
336 const keyOf = new Map<string, string>()
337 const target = (key: string): string => {
338 const known = keyOf.get(key)
339 if (known) return known
340 const root = rootOf(key, o.projects)
341 const name = nameOf(root.key, root.info)
342 const out = (name && byName.get(name)) || root.key
343 if (out === root.key) {
344 if (root.info && !projects[root.key]) projects[root.key] = root.info
345 if (name && !byName.has(name)) byName.set(name, root.key)
346 }
347 keyOf.set(key, out)
348 return out
349 }
350 for (const r of o.rows) if (!isCopy(r.project, o.machine)) rows.push({ ...r, project: target(r.project) })
351 for (const s of o.sessions) if (!isCopy(s.project, o.machine)) sessions.push({ ...s, project: target(s.project) })
352 }
353 return { rows, sessions: sessions.sort((a, b) => a.last - b.last), projects, machines, sessionless }
354}
355
356// ─── Projets ────────────────────────────────────────────────────────────────
357
358/** La clé ccusage d'un dossier : chaque caractère hors lettres et chiffres devient « - ». */
359export const projectKey = (path: string): string => path.replace(/[^a-zA-Z0-9]/g, '-')
360
361/**
362 * Le dossier d'une transcription : le premier `"cwd":"…"` qui donne la clé `key`, à défaut le
363 * premier tout court. Une session commencée ailleurs (un brouillon du desktop rangé ensuite dans
364 * un projet) cite d'abord son dossier de départ.
365 */
366export function cwdOf(text: string, key?: string): string | null {
367 let first: string | null = null
368 for (const m of text.matchAll(/"cwd":"((?:[^"\\]|\\.)*)"/g)) {
369 let path: string
370 try {
371 path = JSON.parse(`"${m[1]}"`) as string
372 } catch {
373 continue
374 }
375 if (!key || projectKey(path) === key) return path
376 first ??= path
377 }
378 return first
379}
380
381const segments = (path: string): string[] => path.split(/[\\/]+/).filter(Boolean)
382
383const WORKTREE_KEY = /--claude-worktrees-.+$/
384const WORKTREE_PATH = /[\\/]\.claude[\\/]worktrees[\\/].*$/
385
386/** Le projet d'un worktree de Claude (`<dépôt>/.claude/worktrees/<nom>`) : celui de son dépôt. */
387export const baseKey = (key: string): string => key.replace(WORKTREE_KEY, '') || key
388
389/**
390 * Un dossier gardé qui ne donne pas sa clé, lu avant que `cwdOf` ne préfère le bon : à relire.
391 * Un worktree lu à son dépôt (session partie du dépôt) compte pour le même projet. Seulement
392 * pour une clé qui est un chemin (pas `ssh-…`) et que Claude Code n'a pas tronquée.
393 */
394export const isStale = (key: string, info: ProjectInfo): boolean =>
395 /^(-|[A-Za-z]--)/.test(key) && key.length <= 200 && !!info.path && baseKey(projectKey(info.path)) !== baseKey(key)
396
397/** La clé et le dossier du projet d'une clé : pour un worktree, ceux du dépôt, déduits du worktree s'ils manquent. */
398function rootOf(key: string, projects: Record<string, ProjectInfo>): { key: string; info: ProjectInfo | undefined } {
399 const base = baseKey(key)
400 if (base === key || projects[base]) return { key: base, info: projects[base] }
401 const path = projects[key]?.path?.replace(WORKTREE_PATH, '')
402 return { key: base, info: path && projectKey(path) === base ? { name: segments(path).pop() ?? '', path } : undefined }
403}
404
405/** Le nom court d'un dossier : son dernier segment, `~` pour le dossier personnel. */
406export function nameOfPath(path: string, home: string | null): string {
407 if (home && segments(path).join('/').toLowerCase() === segments(home).join('/').toLowerCase()) return '~'
408 return segments(path).pop() ?? path
409}
410
411export const isDraft = (key: string, info?: ProjectInfo): boolean => /Claude-scratch-workspaces/i.test(key) || /[\\/]scratch-workspaces[\\/]/i.test(info?.path ?? '')
412
413/** Le projet affiché d'une clé : les brouillons du desktop sont réunis en un seul, un worktree rejoint son dépôt. */
414export const groupOf = (key: string, projects: Record<string, ProjectInfo>): string => (key.startsWith('@') ? key : isDraft(key, projects[key]) ? DRAFTS : baseKey(key))
415
416/** Longueur maximale d'un nom d'affichage de `names.json`. */
417const LABEL_MAX = 60
418
419/**
420 * Les noms d'affichage de `names.json` (dépôt partagé) : `{ "<nom de dossier>": "<nom affiché>" }`. Le nom de
421 * dossier se compare sans la casse, comme au rapprochement des postes ; une entrée vide ou qui n'est pas du texte
422 * est ignorée. `null` si le fichier n'est pas un objet.
423 */
424export function parseLabels(json: unknown): Record<string, string> | null {
425 if (!json || typeof json !== 'object' || Array.isArray(json)) return null
426 const out: Record<string, string> = {}
427 for (const [k, v] of Object.entries(json as Record<string, unknown>)) {
428 const folder = k.trim().toLowerCase()
429 const label = typeof v === 'string' ? v.trim().slice(0, LABEL_MAX) : ''
430 if (folder && label) out[folder] = label
431 }
432 return out
433}
434
435/**
436 * Les projets avec leur nom d'affichage : celui de `names.json` pour le dernier segment de leur dossier (pas « ~ »
437 * pour le dossier personnel, comme le récap), à défaut leur nom, s'il y en a un.
438 */
439export function withLabels(projects: Record<string, ProjectInfo>, labels: Record<string, string>): Record<string, ProjectInfo> {
440 const out: Record<string, ProjectInfo> = {}
441 for (const [k, info] of Object.entries(projects)) {
442 const folder = (info.path ? segments(info.path).pop() : null) ?? info.name
443 const label = folder ? labels[folder.toLowerCase()] : undefined
444 out[k] = label ? { ...info, label } : { name: info.name, path: info.path }
445 }
446 return out
447}
448
449/** Le nom d'un projet affiché ; à défaut de dossier connu, la clé sans le préfixe du dossier personnel. */
450export function projectLabel(key: string, projects: Record<string, ProjectInfo>, homeKey: string | null): string {
451 if (key === DRAFTS) return 'Brouillons du desktop'
452 if (key === UNKNOWN) return 'Sans projet'
453 const info = projects[key]
454 if (info?.name) return info.label || info.name
455 if (homeKey && key === homeKey) return '~'
456 // Sans dossier connu : sans le lecteur ni le dossier personnel ; un worktree de Claude par son nom ;
457 // « -- » est un séparateur suivi d'un point (`\.claude`).
458 const rest = homeKey && key.startsWith(`${homeKey}-`) ? key.slice(homeKey.length + 1) : key.replace(/^[A-Za-z]--/, '')
459 const worktree = rest.match(/--claude-worktrees-(.+)$/)
460 if (worktree) return worktree[1]!
461 return rest.replace(/^-/, '.').replace(/--/g, '/.') || key
462}
463
464/** Les noms affichés de plusieurs projets ; deux noms égaux prennent leur dossier parent. */
465export function projectLabels(keys: readonly string[], projects: Record<string, ProjectInfo>, homeKey: string | null): Map<string, string> {
466 const base = new Map(keys.map((k) => [k, projectLabel(k, projects, homeKey)] as const))
467 const count = new Map<string, number>()
468 base.forEach((v) => count.set(v, (count.get(v) ?? 0) + 1))
469 const out = new Map<string, string>()
470 for (const [k, v] of base) {
471 const parent = projects[k]?.path ? segments(projects[k]!.path!).slice(-2, -1)[0] : undefined
472 out.set(k, (count.get(v) ?? 0) > 1 && parent ? `${parent}/${v}` : v)
473 }
474 return out
475}
476
477// ─── Modèles ────────────────────────────────────────────────────────────────
478
479type ModelId = { family: string; version: number[] }
480
481/** `claude-opus-5-5` → opus 5.5 ; `claude-haiku-4-5-20251001` → haiku 4.5 ; `claude-3-5-sonnet-…` → sonnet 3.5. */
482function parseModel(id: string): ModelId | null {
483 const a = id.match(/^claude-([a-z]+)-([\d-]+?)(?:-\d{8})?$/)
484 if (a) return { family: a[1]!, version: a[2]!.split('-').map(Number) }
485 const b = id.match(/^claude-([\d-]+?)-([a-z]+)(?:-\d{8})?$/)
486 if (b) return { family: b[2]!, version: b[1]!.split('-').map(Number) }
487 return null
488}
489
490const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
491
492export function modelName(id: string): string {
493 if (id === UNKNOWN_MODEL) return 'Modèle inconnu'
494 const m = parseModel(id)
495 return m ? `${cap(m.family)}${NB}${m.version.join('.')}` : id
496}
497
498// Teintes ordonnées pour que deux voisines se distinguent : une famille compte trop de
499// versions (sept Opus) pour des nuances d'une même teinte.
500const MODEL_PALETTE = [C.violet, C.sky, C.amber, C.teal, C.coral, C.pink, C.indigo, C.green, C.magenta, C.cyan, C.tangerine, C.sand, C.blue, C.lilac, C.slate, C.red]
501
502/** Une couleur par modèle, dans l'ordre donné (les plus utilisés d'abord) : les plus utilisés ont les plus distinctes. */
503export function modelColors(ranked: readonly string[]): Record<string, string> {
504 const out: Record<string, string> = {}
505 unique(ranked).forEach((id, i) => (out[id] = MODEL_PALETTE[i % MODEL_PALETTE.length]!))
506 return out
507}
508
509// ─── Dates ──────────────────────────────────────────────────────────────────
510
511/** Le jour local d'un instant, d'après le décalage horaire relevé. */
512export const dayOf = (ms: number, tzOffset: number): string => new Date(ms - tzOffset * 60_000).toISOString().slice(0, 10)
513const utc = (date: string): number => Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)))
514export const addDays = (date: string, n: number): string => new Date(utc(date) + n * DAY_MS).toISOString().slice(0, 10)
515export const daysBetween = (a: string, b: string): number => Math.round((utc(b) - utc(a)) / DAY_MS)
516/** Jour de la semaine, 0 pour lundi. */
517export const weekdayOf = (date: string): number => (new Date(utc(date)).getUTCDay() + 6) % 7
518export const weekOf = (date: string): string => addDays(date, -weekdayOf(date))
519export const monthOf = (date: string): string => date.slice(0, 7)
520const nextMonth = (month: string): string => {
521 const y = Number(month.slice(0, 4))
522 const m = Number(month.slice(5, 7))
523 return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
524}
525export const daysInMonth = (month: string): number => daysBetween(`${month}-01`, `${nextMonth(month)}-01`)
526
527const MONTHS = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']
528const MONTHS_LONG = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
529const WEEKDAYS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']
530export const WEEKDAY_LONG = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
531export const DAY_LETTERS = ['L', 'M', 'M', 'J', 'V', 'S', 'D']
532
533export const monthShort = (date: string): string => MONTHS[Number(date.slice(5, 7)) - 1] ?? date.slice(5, 7)
534/** « 7 oct. », avec l'année si `withYear`. */
535export const fmtDate = (date: string, withYear = false): string =>
536 `${Number(date.slice(8, 10))}${NB}${monthShort(date)}${withYear ? `${NB}${date.slice(0, 4)}` : ''}`
537/** « mar. 7 oct. » */
538export const fmtDay = (date: string): string => `${WEEKDAYS[weekdayOf(date)]}${NB}${fmtDate(date)}`
539/** « octobre 2026 » pour `2026-10`. */
540export const fmtMonth = (month: string): string => `${MONTHS_LONG[Number(month.slice(5, 7)) - 1] ?? month}${NB}${month.slice(0, 4)}`
541export const fmtMonthShort = (month: string): string => `${MONTHS[Number(month.slice(5, 7)) - 1] ?? month}`
542
543/** « à l'instant », « il y a 3 min », « il y a 2 h », « il y a 4 j ». */
544export function ago(ms: number, now: number): string {
545 const s = Math.max(0, Math.round((now - ms) / 1000))
546 if (s < 45) return "à l'instant"
547 if (s < 3600) return `il${NB}y${NB}a${NB}${Math.max(1, Math.round(s / 60))}${NB}min`
548 if (s < 86_400) return `il${NB}y${NB}a${NB}${Math.round(s / 3600)}${NB}h`
549 return `il${NB}y${NB}a${NB}${Math.round(s / 86_400)}${NB}j`
550}
551
552/** « 45 min », « 2 h 05 », « 19 j 23 h ». */
553export function fmtDuration(ms: number): string {
554 const min = Math.round(ms / 60_000)
555 if (min < 1) return `<${NB}1${NB}min`
556 if (min < 60) return `${min}${NB}min`
557 const h = Math.floor(min / 60)
558 if (h < 24) return `${h}${NB}h${NB}${String(min % 60).padStart(2, '0')}`
559 return `${Math.floor(h / 24)}${NB}j${NB}${h % 24}${NB}h`
560}
561
562// ─── Nombres ────────────────────────────────────────────────────────────────
563
564/** Un entier groupé par milliers (espaces insécables) : « 120 394 ». */
565export function fmtInt(n: number): string {
566 const s = String(Math.round(Math.abs(n))).replace(/\B(?=(\d{3})+(?!\d))/g, NB)
567 return n < 0 ? `−${s}` : s
568}
569
570const dec = (n: number, digits: number): string => n.toFixed(digits).replace('.', ',').replace(/,0+$/, '')
571
572/** « 999 », « 85,4 k », « 46,5 M », « 21,9 Md ». */
573export function fmtTokens(n: number): string {
574 const a = Math.abs(n)
575 const unit = a >= 1e9 ? ([1e9, 'Md'] as const) : a >= 1e6 ? ([1e6, 'M'] as const) : a >= 1e3 ? ([1e3, 'k'] as const) : null
576 if (!unit) return String(Math.round(n))
577 const v = n / unit[0]
578 return `${dec(v, Math.abs(v) < 100 ? 1 : 0)}${NB}${unit[1]}`
579}
580
581/** « 4,56 $ », « 9 977 $ ». */
582export function fmtCost(n: number): string {
583 const a = Math.abs(n)
584 if (a === 0) return `0${NB}$`
585 if (a < 0.01) return `<${NB}0,01${NB}$`
586 if (a < 100) return `${n.toFixed(2).replace('.', ',')}${NB}$`
587 return `${fmtInt(n)}${NB}$`
588}
589
590/** Pour les axes et le bandeau : « 47 $ », « 1,2 k$ ». */
591export function fmtCostShort(n: number): string {
592 const a = Math.abs(n)
593 if (a >= 1e3) return `${dec(n / 1e3, a < 1e4 ? 1 : 0)}${NB}k$`
594 if (a >= 10) return `${Math.round(n)}${NB}$`
595 return `${dec(n, 1)}${NB}$`
596}
597
598export const fmtMetric = (n: number, metric: Metric, short = false): string => (metric === 'cost' ? (short ? fmtCostShort(n) : fmtCost(n)) : fmtTokens(n))
599
600export function fmtPct(ratio: number): string {
601 const p = ratio * 100
602 if (p > 0 && p < 1) return `<${NB}1${NB}%`
603 return `${dec(p, p < 10 && p % 1 !== 0 ? 1 : 0)}${NB}%`
604}
605
606/** Une variation signée : « +40 % », « −12 % ». */
607export function fmtDelta(ratio: number): string {
608 const p = Math.round(ratio * 100)
609 return `${p > 0 ? '+' : p < 0 ? '−' : '±'}${Math.abs(p)}${NB}%`
610}
611
612export const plural = (n: number, one: string, many = `${one}s`): string => `${fmtInt(n)}${NB}${n > 1 ? many : one}`
613
614// ─── Filtres et agrégats ────────────────────────────────────────────────────
615
616export type Totals = { input: number; output: number; cacheWrite: number; cacheRead: number; tokens: number; cost: number }
617export const zero = (): Totals => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, tokens: 0, cost: 0 })
618
619const add = (t: Totals, r: UsageRow): Totals => {
620 t.input += r.input
621 t.output += r.output
622 t.cacheWrite += r.cacheWrite
623 t.cacheRead += r.cacheRead
624 t.tokens += r.input + r.output + r.cacheWrite + r.cacheRead
625 t.cost += r.cost
626 return t
627}
628export const sum = (rows: readonly UsageRow[]): Totals => rows.reduce(add, zero())
629export const valueOf = (t: Totals, metric: Metric): number => (metric === 'cost' ? t.cost : t.tokens)
630/** Part du cache lu dans tout ce que le modèle a lu : entrée, cache écrit et cache lu. */
631export const cacheRatio = (t: Totals): number => (t.input + t.cacheWrite + t.cacheRead > 0 ? t.cacheRead / (t.input + t.cacheWrite + t.cacheRead) : 0)
632
633export const PERIOD_DAYS: Record<Exclude<Period, 'all'>, number> = { '90': 90, '30': 30, '7': 7 }
634
635export type Filter = { from: string | null; to: string; machine: Machine; project: string | null; model: string | null }
636
637export const filterOf = (ui: Ui, today: string): Filter => ({
638 from: ui.period === 'all' ? null : addDays(today, -(PERIOD_DAYS[ui.period] - 1)),
639 to: today,
640 machine: ui.machine,
641 project: ui.project,
642 model: ui.model,
643})
644
645/** La même fenêtre, juste avant : de quoi comparer une période ; null pour « Tout ». */
646export function previousOf(f: Filter): Filter | null {
647 if (!f.from) return null
648 const n = daysBetween(f.from, f.to) + 1
649 return { ...f, from: addDays(f.from, -n), to: addDays(f.from, -1) }
650}
651
652export function keepRow(r: UsageRow, f: Filter, projects: Record<string, ProjectInfo>): boolean {
653 if (f.from && r.date < f.from) return false
654 if (r.date > f.to) return false
655 if (f.machine !== 'all' && r.machine !== f.machine) return false
656 if (f.project && groupOf(r.project, projects) !== f.project) return false
657 if (f.model && r.model !== f.model) return false
658 return true
659}
660
661export const selectRows = (rows: readonly UsageRow[], f: Filter, projects: Record<string, ProjectInfo>): UsageRow[] => rows.filter((r) => keepRow(r, f, projects))
662
663/** Les sessions de la période et des filtres. */
664export function selectSessions(sessions: readonly SessionRow[], f: Filter, projects: Record<string, ProjectInfo>, tzOffset: number): SessionRow[] {
665 return sessions.filter((s) => {
666 const day = dayOf(s.last, tzOffset)
667 if (f.machine !== 'all' && s.machine !== f.machine) return false
668 if (f.from && day < f.from) return false
669 if (f.project && groupOf(s.project, projects) !== f.project) return false
670 if (f.model && !s.models.includes(f.model)) return false
671 return true
672 })
673}
674
675export type Bucket = { key: string; total: Totals; byModel: Map<string, Totals> }
676
677const bucketKey = (date: string, grain: Grain): string => (grain === 'day' ? date : grain === 'week' ? weekOf(date) : monthOf(date))
678const nextKey = (key: string, grain: Grain): string => (grain === 'day' ? addDays(key, 1) : grain === 'week' ? addDays(key, 7) : nextMonth(key))
679
680/** Des seaux continus de `from` à `to` (jours, semaines du lundi ou mois), vides compris. */
681export function seriesOf(rows: readonly UsageRow[], grain: Grain, from: string, to: string): Bucket[] {
682 const map = new Map<string, Bucket>()
683 const last = bucketKey(to, grain)
684 for (let key = bucketKey(from, grain), i = 0; key <= last && i < 2000; key = nextKey(key, grain), i++) map.set(key, { key, total: zero(), byModel: new Map() })
685 for (const r of rows) {
686 const b = map.get(bucketKey(r.date, grain))
687 if (!b) continue
688 add(b.total, r)
689 const m = b.byModel.get(r.model) ?? zero()
690 b.byModel.set(r.model, add(m, r))
691 }
692 return [...map.values()]
693}
694
695/** Le grain le plus fin qui tient dans `slots` colonnes ou barres. */
696export function fitGrain(grain: Grain, from: string, to: string, slots: number): Grain {
697 const span = daysBetween(from, to) + 1
698 if (grain === 'day' && span <= slots) return 'day'
699 if (grain !== 'month' && Math.ceil(span / 7) + 1 <= slots) return 'week'
700 return 'month'
701}
702
703export const firstDate = (rows: readonly UsageRow[]): string | null => rows.reduce<string | null>((m, r) => (m === null || r.date < m ? r.date : m), null)
704
705export type ModelStat = { model: string; total: Totals; share: number }
706
707export function modelsOf(rows: readonly UsageRow[], metric: Metric): ModelStat[] {
708 const map = new Map<string, Totals>()
709 for (const r of rows) map.set(r.model, add(map.get(r.model) ?? zero(), r))
710 const all = [...map.values()].reduce((n, t) => n + valueOf(t, metric), 0)
711 return [...map.entries()]
712 .map(([model, total]) => ({ model, total, share: all > 0 ? valueOf(total, metric) / all : 0 }))
713 .sort((a, b) => valueOf(b.total, metric) - valueOf(a.total, metric) || b.total.tokens - a.total.tokens)
714}
715
716/** Un projet affiché : son total, sa part par poste, ses sessions. */
717export type ProjectStat = { key: string; total: Totals; byMachine: Record<string, Totals>; share: number; sessions: number; first: string; last: string; members: number }
718
719export function projectsOf(rows: readonly UsageRow[], sessions: readonly SessionRow[], projects: Record<string, ProjectInfo>, metric: Metric): ProjectStat[] {
720 const map = new Map<string, { total: Totals; byMachine: Record<string, Totals>; first: string; last: string; members: Set<string> }>()
721 for (const r of rows) {
722 const key = groupOf(r.project, projects)
723 const x = map.get(key) ?? { total: zero(), byMachine: {}, first: r.date, last: r.date, members: new Set<string>() }
724 add(x.total, r)
725 x.byMachine[r.machine] = add(x.byMachine[r.machine] ?? zero(), r)
726 if (r.date > x.last) x.last = r.date
727 if (r.date < x.first) x.first = r.date
728 x.members.add(r.project)
729 map.set(key, x)
730 }
731 const counts = new Map<string, number>()
732 for (const s of sessions) {
733 const key = groupOf(s.project, projects)
734 counts.set(key, (counts.get(key) ?? 0) + 1)
735 }
736 const all = [...map.values()].reduce((n, x) => n + valueOf(x.total, metric), 0)
737 return [...map.entries()]
738 .map(([key, x]) => ({ key, total: x.total, byMachine: x.byMachine, share: all > 0 ? valueOf(x.total, metric) / all : 0, sessions: counts.get(key) ?? 0, first: x.first, last: x.last, members: x.members.size }))
739 .sort((a, b) => valueOf(b.total, metric) - valueOf(a.total, metric))
740}
741
742/** Les jours actifs, triés. */
743export function activeDates(rows: readonly UsageRow[]): string[] {
744 return unique(rows.filter((r) => r.input + r.output + r.cacheWrite + r.cacheRead > 0).map((r) => r.date)).sort()
745}
746
747/** La plus longue série de jours actifs et la série en cours (qui finit aujourd'hui ou hier). */
748export function streaksOf(dates: readonly string[], today: string): { longest: number; longestEnd: string | null; current: number } {
749 let longest = 0
750 let longestEnd: string | null = null
751 let run = 0
752 let prev: string | null = null
753 for (const d of dates) {
754 run = prev !== null && daysBetween(prev, d) === 1 ? run + 1 : 1
755 if (run > longest) {
756 longest = run
757 longestEnd = d
758 }
759 prev = d
760 }
761 const set = new Set(dates)
762 let current = 0
763 let day = set.has(today) ? today : addDays(today, -1)
764 while (set.has(day)) {
765 current += 1
766 day = addDays(day, -1)
767 }
768 return { longest, longestEnd, current }
769}
770
771/** Le mois en cours : dépensé, projection linéaire à la fin du mois, et le mois d'avant. */
772export function monthPlan(rows: readonly UsageRow[], today: string, metric: Metric) {
773 const month = monthOf(today)
774 const prev = monthOf(addDays(`${month}-01`, -1))
775 const day = Number(today.slice(8, 10))
776 const total = daysInMonth(month)
777 let spent = 0
778 let lastMonth = 0
779 let lastMonthToDate = 0
780 for (const r of rows) {
781 const v = metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead
782 const m = monthOf(r.date)
783 if (m === month && r.date <= today) spent += v
784 else if (m === prev) {
785 lastMonth += v
786 if (Number(r.date.slice(8, 10)) <= day) lastMonthToDate += v
787 }
788 }
789 return { month, prev, day, total, spent, projected: day > 0 ? (spent / day) * total : spent, lastMonth, lastMonthToDate }
790}
791
792/** Le total de la mesure par jour de la semaine, lundi d'abord. */
793export function weekdaysOf(rows: readonly UsageRow[], metric: Metric): number[] {
794 const out = [0, 0, 0, 0, 0, 0, 0]
795 for (const r of rows) out[weekdayOf(r.date)]! += metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead
796 return out
797}
798
799// Ordres de grandeur en tokens (estimations : ~1,4 token par mot français).
800const BOOKS = [
801 { title: 'Le Petit Prince', tokens: 25_000 },
802 { title: "Harry Potter à l'école des sorciers", tokens: 110_000 },
803 { title: 'Les Misérables', tokens: 750_000 },
804 { title: 'À la recherche du temps perdu', tokens: 1_800_000 },
805] as const
806
807/** Une comparaison parlante pour les tokens de sortie : le plus gros livre écrit au moins trois fois. */
808export function funFact(output: number): string | null {
809 if (output < 1000) return null
810 const book = [...BOOKS].reverse().find((b) => output / b.tokens >= 3) ?? BOOKS[0]
811 const ratio = output / book.tokens
812 const times = ratio >= 10 ? fmtInt(ratio) : dec(ratio, 1)
813 return `Claude t'a écrit ~${times}${NB}× ${book.title} (${fmtTokens(output)} tokens de sortie).`
814}
815
816// ─── Journée ────────────────────────────────────────────────────────────────
817
818/** Une discussion d'un jour : la session principale et ses sous-agents de workflow, réunis. */
819export type DayDiscussion = { id: string; machine: string; project: string; first: number; last: number; cost: number; tokens: number; models: string[]; agents: number }
820
821/**
822 * Les discussions actives un jour donné : commencées ou terminées ce jour-là (ccusage ne donne
823 * que la première et la dernière activité). Une discussion compte avec tous ses sous-agents de
824 * workflow dès que l'un d'eux est actif ce jour-là ; son coût est son total, jours précédents
825 * compris. Les plus chères d'abord.
826 */
827export function discussionsOfDay(sessions: readonly SessionRow[], day: string, tzOffset: number): DayDiscussion[] {
828 const isOn = (s: SessionRow) => dayOf(s.first, tzOffset) === day || dayOf(s.last, tzOffset) === day
829 const ids = new Set(sessions.filter(isOn).map((s) => s.parent ?? s.id))
830 const out = new Map<string, DayDiscussion>()
831 for (const s of sessions) {
832 const id = s.parent ?? s.id
833 if (!ids.has(id)) continue
834 const d = out.get(id) ?? { id, machine: s.machine, project: s.project, first: s.first, last: s.last, cost: 0, tokens: 0, models: [], agents: 0 }
835 if (!s.parent) {
836 d.machine = s.machine
837 d.project = s.project
838 } else d.agents += 1
839 d.first = Math.min(d.first, s.first)
840 d.last = Math.max(d.last, s.last)
841 d.cost += s.cost
842 d.tokens += s.tokens
843 d.models = unique([...d.models, ...s.models])
844 out.set(id, d)
845 }
846 return [...out.values()].sort((a, b) => b.cost - a.cost || b.last - a.last)
847}
848
849/**
850 * Un jour dans les 30 derniers jours, lui compris : la veille, la moyenne par jour actif avant
851 * lui dans la fenêtre, et son rang parmi les jours actifs de la fenêtre (null s'il n'a rien consommé).
852 */
853export function compareDay(rows: readonly UsageRow[], day: string, metric: Metric): { value: number; previous: number; average: number | null; rank: number | null; activeDays: number } {
854 const from = addDays(day, -29)
855 const byDate = new Map<string, number>()
856 for (const r of rows) {
857 if (r.date < from || r.date > day) continue
858 byDate.set(r.date, (byDate.get(r.date) ?? 0) + (metric === 'cost' ? r.cost : r.input + r.output + r.cacheWrite + r.cacheRead))
859 }
860 const value = byDate.get(day) ?? 0
861 const before = [...byDate.entries()].filter(([d, v]) => d < day && v > 0).map(([, v]) => v)
862 return {
863 value,
864 previous: byDate.get(addDays(day, -1)) ?? 0,
865 average: before.length ? before.reduce((n, v) => n + v, 0) / before.length : null,
866 rank: value > 0 ? 1 + before.filter((v) => v > value).length : null,
867 activeDays: before.length + (value > 0 ? 1 : 0),
868 }
869}
870
871/**
872 * Le jour montré par l'onglet Journée : le jour choisi, ramené entre le premier jour connu et
873 * celui du relevé (rien n'existe après lui), ou celui du relevé. Avec les jours actifs voisins
874 * (`dates`, sous les filtres), null quand il n'y en a pas.
875 */
876export function dayChoice(chosen: string | null, latest: string, first: string | null, dates: readonly string[]): { day: string; first: string; latest: string; prevActive: string | null; nextActive: string | null } {
877 const start = first !== null && first < latest ? first : latest
878 const day = chosen === null || chosen > latest ? latest : chosen < start ? start : chosen
879 let prevActive: string | null = null
880 let nextActive: string | null = null
881 for (const d of dates) {
882 if (d < day && d >= start && (prevActive === null || d > prevActive)) prevActive = d
883 if (d > day && d <= latest && (nextActive === null || d < nextActive)) nextActive = d
884 }
885 return { day, first: start, latest, prevActive, nextActive }
886}
887
888/** Les blocs de 5 h commencés ou terminés ce jour-là. */
889export const blocksOfDay = (blocks: readonly BlockRow[], day: string, tzOffset: number): BlockRow[] =>
890 blocks.filter((b) => dayOf(b.start, tzOffset) === day || dayOf(b.lastAt, tzOffset) === day)
891
892/** Le rythme d'un bloc : celui de ccusage pour le bloc en cours, sinon sa dépense par heure active (comme les seuils) ; null sous 6 min. */
893export function blockRate(b: BlockRow): number | null {
894 if (b.costPerHour !== null) return b.costPerHour
895 const hours = (b.lastAt - b.start) / 3_600_000
896 return hours > 0.1 ? b.cost / hours : null
897}
898
899// ─── Rythme horaire ─────────────────────────────────────────────────────────
900
901/**
902 * Les seuils du rythme horaire, en $/h : tirés des 45 blocs de 5 h du 9 septembre au 7 octobre
903 * 2026 (dépense du bloc par heure active) : 1er quartile 6,9, médiane 13,5, 3e quartile 21,7,
904 * 9e décile 31,9, maximum 71.
905 */
906export const RATE_LEVELS = [7, 14, 22, 32] as const
907
908export type Heat = { level: 0 | 1 | 2 | 3 | 4; label: string; color: string }
909
910const HEATS: readonly Heat[] = [
911 { level: 0, label: 'calme', color: C.teal },
912 { level: 1, label: 'posé', color: C.amber },
913 { level: 2, label: 'soutenu', color: C.tangerine },
914 { level: 3, label: 'intense', color: C.coral },
915 { level: 4, label: 'en feu', color: C.red },
916]
917
918/** Le niveau d'un rythme horaire : calme, posé, soutenu, intense, en feu. */
919export const heatOf = (perHour: number): Heat => HEATS[RATE_LEVELS.filter((t) => perHour >= t).length]!
920
921/** La légende des niveaux : « calme < 7 · posé 7–14 · … · en feu ≥ 32 $/h ». */
922export const heatLegend = (): string =>
923 HEATS.map((h, i) => (i === 0 ? `${h.label} <${NB}${RATE_LEVELS[0]}` : i === HEATS.length - 1 ? `${h.label} ≥${NB}${RATE_LEVELS[i - 1]}` : `${h.label} ${RATE_LEVELS[i - 1]}–${RATE_LEVELS[i]}`)).join(' · ') + `${NB}$/h`
924
925// ─── Discussion ouverte ─────────────────────────────────────────────────────
926
927/** `ccusage claude session --id <id> --json` : `{ sessionId, totalCost, totalTokens, entries }`. */
928export function discussionOf(json: Json): { cost: number; tokens: number; entries: DiscussionEntry[] } {
929 const entries: DiscussionEntry[] = []
930 for (const e of Array.isArray(json?.entries) ? json.entries : []) {
931 const at = Date.parse(str(e?.timestamp))
932 const model = str(e?.model)
933 if (!Number.isFinite(at) || !model || model === '<synthetic>') continue
934 entries.push([at, model, num(e.inputTokens), num(e.outputTokens), num(e.cacheCreationTokens), num(e.cacheReadTokens)])
935 }
936 entries.sort((a, b) => a[0] - b[0])
937 return { cost: num(json?.totalCost), tokens: num(json?.totalTokens), entries }
938}
939
940/** Les poids relatifs du barème d'Anthropic, l'entrée valant 1 : cache écrit 1,25, cache lu 0,1, sortie 5. */
941const units = (input: number, output: number, cacheWrite: number, cacheRead: number): number => input + 1.25 * cacheWrite + 0.1 * cacheRead + 5 * output
942
943/** Le prix d'une unité pondérée, par modèle, d'après le coût et les tokens de tout le relevé. */
944export function unitPricesOf(rows: readonly UsageRow[]): Record<string, number> {
945 const acc = new Map<string, { cost: number; units: number }>()
946 for (const r of rows) {
947 const x = acc.get(r.model) ?? { cost: 0, units: 0 }
948 x.cost += r.cost
949 x.units += units(r.input, r.output, r.cacheWrite, r.cacheRead)
950 acc.set(r.model, x)
951 }
952 const out: Record<string, number> = {}
953 for (const [model, x] of acc) if (x.cost > 0 && x.units > 0) out[model] = x.cost / x.units
954 return out
955}
956
957/**
958 * Le coût estimé de chaque appel : ccusage ne donne que le total de la discussion. Chaque appel
959 * reçoit ses unités pondérées (barème relatif) au prix unitaire de son modèle, puis le tout est
960 * ramené au total exact de ccusage.
961 */
962export function estimateCosts(entries: readonly DiscussionEntry[], total: number, prices: Record<string, number>): number[] {
963 const known = Object.values(prices)
964 const fallback = known.length ? known.reduce((n, v) => n + v, 0) / known.length : 1
965 const raw = entries.map(([, model, i, o, cw, cr]) => units(i, o, cw, cr) * (prices[model] ?? fallback))
966 const all = raw.reduce((n, v) => n + v, 0)
967 return all > 0 ? raw.map((v) => (v / all) * total) : raw.map(() => 0)
968}
969
970export const entryTokens = (e: DiscussionEntry): number => e[2] + e[3] + e[4] + e[5]
971
972/** La durée d'un seau de la frise : la plus courte qui tient en 60 seaux au plus. */
973export function bucketMinutes(from: number, to: number, slots = 60): number {
974 const span = Math.max(1, (to - from) / 60_000)
975 return [5, 15, 30, 60, 180, 360, 720, 1440, 2880].find((m) => span / m <= slots) ?? 10_080
976}
977
978/** Le texte de la ligne d'état quand le bandeau est masqué. */
979export function statusText(o: { perHour: number | null; discussion: number | null; today: number }): string {
980 const parts: string[] = []
981 if (o.perHour !== null) parts.push(`${o.perHour >= RATE_LEVELS[3] ? '🔥' : '◆'} ${fmtCostShort(o.perHour)}/h`)
982 if (o.discussion !== null) parts.push(`discussion ${fmtCost(o.discussion)}`)
983 parts.push(`${fmtCost(o.today)} aujourd'hui`)
984 return parts.join(' · ').replace(new RegExp(NB, 'g'), ' ')
985}
986hooks/view.tsx 2074 lines1// Dessin du panneau de statistiques (/ccstats) et du bandeau au-dessus du prompt. Un seul
2// arbre pour toutes les surfaces : le desktop, VS Code et le mobile reçoivent des SVG, le
3// terminal des grilles de cases (`Raster`) pour le calendrier et les colonnes, des glyphes et
4// des barres en blocs pour le reste (et pour tout, si sa table n'a pas `Raster`). Aucun appel
5// au moteur ici : l'état arrive dans `View`, les actions dans `Actions` (des fermetures préparées par register.tsx).
6//
7// Pièges du desktop (relevés par git-board) : les espaces ordinaires y sont fondus et `gap`
8// ignoré ; les écarts dans un texte sont donc insécables, ceux entre éléments des marges.
9// Aucune prop n'est passée à `undefined` : les arbres restent valides partout.
10
11import type { Discussion, Expanded, Grain, Machine, Meta, Metric, Notice, Period, SessionRow, SessionSort, Snapshot, Tab, Ui, UsageRow } from '../types'
12import {
13 BRAND,
14 C,
15 DAY_LETTERS,
16 DRAFTS,
17 MONEY,
18 NB,
19 RATE_LEVELS,
20 SEP,
21 WEEKDAY_LONG,
22 activeDates,
23 blockRate,
24 blocksOfDay,
25 bucketMinutes,
26 compareDay,
27 dayChoice,
28 discussionsOfDay,
29 entryTokens,
30 estimateCosts,
31 heatLegend,
32 heatOf as rateHeat,
33 unitPricesOf,
34 addDays,
35 ago,
36 cacheRatio,
37 dayOf,
38 daysBetween,
39 filterOf,
40 firstDate,
41 fitGrain,
42 fmtCost,
43 fmtCostShort,
44 fmtDate,
45 fmtDay,
46 fmtDelta,
47 fmtDuration,
48 fmtInt,
49 fmtMetric,
50 fmtMonth,
51 fmtMonthShort,
52 fmtPct,
53 fmtTokens,
54 funFact,
55 groupOf,
56 modelColors,
57 modelName,
58 modelsOf,
59 monthOf,
60 monthPlan,
61 monthShort,
62 plural,
63 previousOf,
64 projectLabels,
65 projectsOf,
66 selectRows,
67 selectSessions,
68 seriesOf,
69 streaksOf,
70 sum,
71 unique,
72 valueOf,
73 weekOf,
74 weekdaysOf,
75 zero,
76 type Filter,
77 type Totals,
78} from './model.ts'
79import { DEFAULT, columnsCells, heatCells, heatLegendCells, pack, type RasterCells } from './raster.ts'
80import { barSvg, blockSvg, columnsSvg, donutSvg, flameSvg, heatGaugeSvg, heatmapSvg, heroSvg, ringSvg, sparkSvg, spinnerSvg, stackSvg, type BarPart, type HeatCell } from './svg.ts'
81
82/** Largeur estimée d'une cellule sur les surfaces distantes ; un SVG trop large est réduit à sa place. */
83const PX_PER_CELL = 7.6
84/** Plafonds des listes avant « ▸ tout voir (N) ». */
85const MODELS_CAP = 6
86const PROJECTS_CAP = 8
87const SESSIONS_CAP = 10
88const SESSIONS_MAX = 40
89const DAYS_CAP = 5
90const DAYS_MAX = 15
91
92export type Kit = {
93 Box: any
94 Text: any
95 Button: any
96 Svg: any
97 /**
98 * La grille de cases du terminal, ou null : les graphiques passent alors aux glyphes. Jamais
99 * donnée à une surface qui dessine les SVG : la table complétée de `$.ui.resolve` y porte un
100 * `Raster` (fragment vide, constaté dans le kit de test), et le SVG garde la priorité.
101 */
102 Raster: any
103 /** La surface dessine les SVG (desktop, VS Code, mobile). */
104 isRich: boolean
105 /** Colonnes du corps. */
106 width: number
107}
108
109/**
110 * Ce que la surface dessine vraiment. La table de `$.ui.resolve(e)` est complétée : un
111 * élément absent de la surface y figure quand même et dessine un fragment vide (constaté par
112 * git-board : `Svg` sur le terminal). register.tsx lit donc la capacité dans le contrat
113 * `Elements` du moteur et la passe ici ; la vue ne connaît pas le nom de la surface.
114 */
115export type Caps = { hasSvg: boolean }
116
117/** Une case vide, pour essayer le constructeur `Raster` de la table. */
118const PROBE = pack([[[0x20, DEFAULT, DEFAULT]]])
119
120/**
121 * La table a vraiment `Raster` : le constructeur existe et rend un élément `Raster`. Un moteur
122 * qui ne le connaît pas n'en a pas ; un `Raster` retenu par un hook `ui.resolve` est complété
123 * par un fragment vide (constaté dans le kit de test), qui ne dessinerait rien.
124 */
125const drawsRaster = (ctor: unknown): boolean => {
126 if (typeof ctor !== 'function') return false
127 try {
128 return (ctor as (props: unknown) => { type?: unknown } | null)({ key: 'probe', columns: 1, rows: 1, cells: PROBE.cells })?.type === 'Raster'
129 } catch {
130 return false
131 }
132}
133
134export const kitOf = (table: Record<string, unknown>, width: number, caps: Caps): Kit => ({
135 Box: table.Box,
136 Text: table.Text,
137 Button: table.Button,
138 Svg: caps.hasSvg ? (table.Svg ?? null) : null,
139 Raster: !caps.hasSvg && drawsRaster(table.Raster) ? table.Raster : null,
140 isRich: caps.hasSvg && typeof table.Svg === 'function',
141 width: Math.max(24, Math.floor(width)),
142})
143
144export type View = {
145 snapshot: Snapshot | null
146 ui: Ui
147 meta: Meta
148 notice: Notice
149 isBandHidden: boolean
150 /** Clé ccusage du dossier de la session. */
151 here: string | null
152 discussion: Discussion
153 now: number
154}
155
156export type Actions = {
157 setUi: (patch: Partial<Ui>) => unknown
158 toggleExpanded: (list: keyof Expanded) => unknown
159 /** ↻ : relance ccusage, relit le cache de /stats et détaille la discussion. */
160 refresh: () => unknown
161 /** ↻ de l'onglet Discussion : ccusage sur cette seule discussion. */
162 refreshDiscussion: () => unknown
163 dismissNotice: () => unknown
164 hideBand: () => unknown
165 openPane: () => unknown
166}
167
168// ─── Petits outils ────────────────────────────────────────────────────────
169
170const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
171const spinFrame = (now: number): string => SPIN[Math.floor(now / 1000) % SPIN.length] ?? '⠋'
172const px = (cells: number): number => Math.max(160, Math.floor(cells * PX_PER_CELL))
173const EIGHTHS = '▁▂▃▄▅▆▇█'
174
175type Style = { color?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; wrap?: string }
176
177const propsOf = (style: Style): Record<string, unknown> => {
178 const props: Record<string, unknown> = {}
179 if (style.color) props.color = style.color
180 if (style.bg) props.backgroundColor = style.bg
181 if (style.bold) props.bold = true
182 if (style.dim) props.dimColor = true
183 if (style.italic) props.italic = true
184 if (style.wrap) props.wrap = style.wrap
185 return props
186}
187
188const isShown = (item: unknown): boolean => item !== null && item !== undefined && item !== false && item !== ''
189
190/** Un Text sans prop indéfinie. */
191const T = (k: Kit, text: string, style: Style = {}): unknown => h(k.Text, propsOf(style), text)
192
193/** Un Text fait de morceaux (Text imbriqués ou chaînes), sans prop indéfinie. */
194const TT = (k: Kit, parts: unknown[], style: Style = {}): unknown => h(k.Text, propsOf(style), ...parts.filter(isShown))
195
196/** Pastille pleine : texte foncé sur la couleur. */
197const chip = (k: Kit, text: string, color: string, style: Style = {}): unknown => T(k, `${NB}${text}${NB}`, { ...style, bg: color, color: C.ink, bold: true })
198
199/** Une rangée d'éléments séparés par une marge (le desktop ignore `gap`). */
200const line = (k: Kit, items: unknown[], wrap = false): unknown => {
201 const { Box } = k
202 const list = items.filter(isShown)
203 return (
204 <Box flexDirection="row" alignItems="center" flexWrap={wrap ? 'wrap' : 'nowrap'}>
205 {list.map((item, i) => (i < list.length - 1 ? <Box marginRight={1}>{item}</Box> : item))}
206 </Box>
207 )
208}
209
210const svgOf = (k: Kit, drawn: { source: string; width: number; height: number }, alt: string, isSized = true): unknown => {
211 const props: Record<string, unknown> = { source: drawn.source, alt }
212 if (isSized) {
213 props.width = drawn.width
214 props.height = drawn.height
215 }
216 // Jamais `isInteractive` : ce mode dessine dans un cadre à fond blanc, rechargé à chaque redessin.
217 return h(k.Svg, props)
218}
219
220/** Barre en blocs du terminal. */
221const blocks = (k: Kit, parts: readonly BarPart[], total: number, cells: number): unknown => {
222 const { Box } = k
223 const width = Math.max(4, cells)
224 const all = Math.max(1e-9, total, parts.reduce((n, p) => n + p.value, 0))
225 let used = 0
226 const segs: unknown[] = []
227 parts.forEach((part) => {
228 if (part.value <= 0) return
229 const n = Math.max(1, Math.round((part.value / all) * width))
230 const take = Math.min(n, width - used)
231 if (take <= 0) return
232 used += take
233 segs.push(T(k, '█'.repeat(take), { color: part.color }))
234 })
235 if (used < width) segs.push(T(k, '░'.repeat(width - used), { color: C.gray, dim: true }))
236 return <Box flexDirection="row">{segs}</Box>
237}
238
239/** Une jauge horizontale : SVG arrondi, ou blocs sur le terminal. */
240const meter = (k: Kit, parts: readonly BarPart[], total: number, cells: number, alt: string): unknown =>
241 k.isRich ? svgOf(k, barSvg(parts, total, px(cells), 7), alt) : blocks(k, parts, total, cells)
242
243/** Indicateur de travail : arc qui tourne (SVG) ou spinner braille (terminal). */
244const spinner = (k: Kit, color: string, now: number, alt: string): unknown => (k.isRich ? svgOf(k, spinnerSvg(color, 12), alt) : T(k, spinFrame(now), { color }))
245
246/** Bascule « ▸ tout voir (N) » / « ▾ réduire » d'une liste plafonnée. */
247const showAllToggle = (k: Kit, key: string, isOpen: boolean, total: number, onPress: () => unknown, more = `tout${NB}voir${NB}(${total})`): unknown => {
248 const { Box, Button } = k
249 return (
250 <Box marginTop={1}>
251 <Button key={key} plain={true} dimColor={true} label={isOpen ? `▾${NB}réduire` : `▸${NB}${more}`} onPress={onPress} />
252 </Box>
253 )
254}
255
256/** Un choix d'une rangée de bascules : le choix courant marqué « ▸ », les autres atténués. */
257const toggle = (k: Kit, key: string, label: string, isOn: boolean, onPress: () => unknown): unknown =>
258 h(k.Button, { key, plain: true, dimColor: !isOn, label: isOn ? `▸${NB}${label}` : label, onPress })
259
260/** Une valeur seule par jour, échantillonnée sur `cells` colonnes au plus (sommes par paquet). */
261const resample = (values: readonly number[], cells: number): number[] => {
262 if (values.length <= cells) return [...values]
263 const size = Math.ceil(values.length / cells)
264 const out: number[] = []
265 for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size).reduce((n, v) => n + v, 0))
266 return out
267}
268
269/** Une courbe cumulée, échantillonnée sur `cells` points au plus : la dernière valeur de chaque paquet. */
270const sampleLast = (values: readonly number[], cells: number): number[] => {
271 if (values.length <= cells) return [...values]
272 const size = Math.ceil(values.length / cells)
273 const out: number[] = []
274 for (let i = size - 1; i < values.length + size - 1; i += size) out.push(values[Math.min(i, values.length - 1)]!)
275 return out
276}
277
278/** Courbe d'une ligne en huitièmes de bloc (terminal). */
279const sparkText = (k: Kit, values: readonly number[], cells: number, color: string): unknown => {
280 const vals = resample(values, Math.max(4, cells))
281 const max = Math.max(...vals, 1e-9)
282 const text = vals.map((v) => (v <= 0 ? '▁' : EIGHTHS[Math.min(7, Math.max(0, Math.ceil((v / max) * 8) - 1))] ?? '▁')).join('')
283 return T(k, text, { color })
284}
285
286/**
287 * Colonnes sur plusieurs rangées en huitièmes de bloc (terminal) ; `cell` caractères par
288 * colonne. Avec `stacks` (les parts de chaque colonne, du bas vers le haut), chaque case prend
289 * la couleur de la part qu'elle couvre : des barres empilées par modèle.
290 */
291const columnsText = (k: Kit, values: readonly number[], height: number, color: string, cell = 1, highlight = -1, stacks: readonly (readonly BarPart[])[] | null = null): unknown => {
292 const { Box } = k
293 const max = Math.max(...values, 1e-9)
294 const tall = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * height * 8)) : 0))
295 const colorAt = (i: number, at8: number): string => {
296 const parts = stacks?.[i]
297 const all = values[i] ?? 0
298 if (!parts || all <= 0) return i === highlight ? C.coral : color
299 let top = 0
300 for (const p of parts) {
301 top += (Math.max(0, p.value) / all) * (tall[i] ?? 0)
302 if (at8 < top) return p.color
303 }
304 return parts[parts.length - 1]?.color ?? color
305 }
306 const rows: unknown[] = []
307 for (let r = height - 1; r >= 0; r--) {
308 const runs: { text: string; color: string }[] = []
309 tall.forEach((h8, i) => {
310 const fill = Math.max(0, Math.min(8, h8 - r * 8))
311 const glyph = fill === 0 ? NB : (EIGHTHS[fill - 1] ?? '█')
312 const text = cell > 1 ? glyph.repeat(cell - 1) + NB : glyph
313 const c = colorAt(i, r * 8 + fill / 2)
314 const last = runs[runs.length - 1]
315 if (last && last.color === c) last.text += text
316 else runs.push({ text, color: c })
317 })
318 rows.push(<Box key={`r${r}`}>{TT(k, runs.map((x) => T(k, x.text, { color: x.color })))}</Box>)
319 }
320 return <Box flexDirection="column">{rows}</Box>
321}
322
323/** Une grille de cases (`Raster`) ; `key` unique parmi celles d'un même arbre. */
324const raster = (k: Kit, key: string, drawn: RasterCells): unknown => h(k.Raster, { key, columns: drawn.columns, rows: drawn.rows, cells: drawn.cells })
325
326/** Colonnes du terminal : une grille de cases quand la table a `Raster`, sinon des glyphes. */
327const columnsChart = (k: Kit, key: string, values: readonly number[], height: number, color: string, cell = 1, highlight = -1, stacks: readonly (readonly BarPart[])[] | null = null): unknown =>
328 k.Raster && values.length > 0 && values.length * cell <= 512 ? raster(k, key, columnsCells(values, height, color, cell, highlight, stacks)) : columnsText(k, values, height, color, cell, highlight, stacks)
329
330const TONE = { ok: C.green, error: C.red, info: C.tangerine } as const
331const toneOf = (metric: Metric): string => (metric === 'cost' ? MONEY : BRAND)
332const hhmm = (ms: number, tz: number): string => new Date(ms - tz * 60_000).toISOString().slice(11, 16)
333const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
334
335// ─── Contexte d'un dessin ─────────────────────────────────────────────────
336
337type Ctx = {
338 s: Snapshot
339 ui: Ui
340 now: number
341 today: string
342 f: Filter
343 rows: UsageRow[]
344 total: Totals
345 /** Premier jour montré : début de la période, ou premier jour connu pour « Tout ». */
346 from: string
347 metric: Metric
348 colors: Record<string, string>
349 names: Map<string, string>
350 sessions: SessionRow[]
351 /** Le projet de la session, tel qu'affiché (les brouillons réunis). */
352 here: string | null
353 /** Colonnes dans une carte bordée. */
354 inner: number
355}
356
357function contextOf(k: Kit, view: View): Ctx | null {
358 const s = view.snapshot
359 if (!s) return null
360 const today = dayOf(view.now, s.tzOffset)
361 const machine = view.ui.machine === 'all' || s.machines.includes(view.ui.machine) ? view.ui.machine : 'all'
362 const f = filterOf({ ...view.ui, machine }, today)
363 const rows = selectRows(s.rows, f, s.projects)
364 const homeKey = Object.entries(s.projects).find(([, i]) => i.name === '~')?.[0] ?? null
365 const keys = unique([...s.rows.map((r) => groupOf(r.project, s.projects)), ...s.sessions.map((x) => groupOf(x.project, s.projects))])
366 return {
367 s,
368 ui: view.ui,
369 now: view.now,
370 today,
371 f,
372 rows,
373 total: sum(rows),
374 from: f.from ?? firstDate(rows) ?? today,
375 metric: view.ui.metric,
376 // Couleurs sur tout le relevé, pas sur la vue filtrée : un modèle garde la sienne d'un filtre à l'autre.
377 colors: modelColors(modelsOf(s.rows, 'tokens').map((m) => m.model)),
378 names: projectLabels(keys, s.projects, homeKey),
379 sessions: selectSessions(s.sessions, f, s.projects, s.tzOffset),
380 here: view.here ? groupOf(view.here, s.projects) : null,
381 inner: Math.max(20, k.width - 6),
382 }
383}
384
385const nameOf = (ctx: Ctx, key: string): string => ctx.names.get(key) ?? key
386
387/** Une carte bordée : un titre de couleur, des réglages à droite (qui passent dessous si la place manque). */
388const card = (k: Kit, key: string, title: string, color: string, right: unknown, body: unknown[]): unknown => {
389 const { Box } = k
390 return (
391 <Box key={key} flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
392 <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap">
393 <Box flexShrink={1} marginRight={1}>
394 {T(k, title, { color, bold: true, wrap: 'truncate-end' })}
395 </Box>
396 {isShown(right) && <Box flexShrink={0}>{right}</Box>}
397 </Box>
398 {body.filter(isShown)}
399 </Box>
400 )
401}
402
403const say = (k: Kit, text: string, style: Style = { dim: true }): unknown => {
404 const { Box } = k
405 return <Box marginTop={1}>{T(k, text, { wrap: 'wrap', ...style })}</Box>
406}
407
408const metricToggle = (k: Kit, ctx: Ctx, act: Actions): unknown =>
409 line(k, [
410 toggle(k, 'metric-cost', `$${NB}coût`, ctx.metric === 'cost', () => act.setUi({ metric: 'cost' })),
411 toggle(k, 'metric-tokens', `#${NB}tokens`, ctx.metric === 'tokens', () => act.setUi({ metric: 'tokens' })),
412 ])
413
414/** Des pastilles de couleur, une par modèle. */
415const modelDots = (k: Kit, ctx: Ctx, models: readonly string[]): unknown => TT(k, models.map((m) => T(k, '●', { color: ctx.colors[m] ?? C.gray })))
416
417// ─── Panneau ──────────────────────────────────────────────────────────────
418
419const TABS: [Tab, string][] = [
420 ['discussion', 'Discussion'],
421 ['day', 'Journée'],
422 ['overview', 'Aperçu'],
423 ['trend', 'Évolution'],
424 ['models', 'Modèles'],
425 ['projects', 'Projets'],
426 ['sessions', 'Sessions'],
427 ['rhythm', 'Rythme'],
428]
429
430export const drawPane = (k: Kit, view: View, act: Actions): unknown => {
431 const { Box } = k
432 const ctx = contextOf(k, view)
433 if (!ctx) {
434 return (
435 <Box flexDirection="column" paddingX={1} paddingBottom={1}>
436 {welcome(k, view, act)}
437 {activity(k, view, act)}
438 </Box>
439 )
440 }
441 const tab = view.ui.tab
442 const body =
443 tab === 'discussion'
444 ? discussionTab(k, view, ctx, act)
445 : tab === 'day'
446 ? dayTab(k, view, ctx, act)
447 : ctx.rows.length === 0
448 ? emptyCard(k, act)
449 : tab === 'trend'
450 ? trendTab(k, ctx, act)
451 : tab === 'models'
452 ? modelsTab(k, ctx, act)
453 : tab === 'projects'
454 ? projectsTab(k, ctx, act)
455 : tab === 'sessions'
456 ? sessionsTab(k, ctx, act)
457 : tab === 'rhythm'
458 ? rhythmTab(k, ctx, act)
459 : overviewTab(k, ctx, act)
460 return (
461 <Box flexDirection="column" paddingX={1} paddingBottom={1}>
462 {header(k, view, ctx, act)}
463 {activity(k, view, act)}
464 {tabsRow(k, view, act)}
465 {body}
466 </Box>
467 )
468}
469
470/** Sans relevé : une carte d'accueil, jamais un panneau vide. */
471const welcome = (k: Kit, view: View, act: Actions): unknown => {
472 const { Box, Button } = k
473 const m = view.meta
474 return (
475 <Box key="welcome" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={2} paddingY={1}>
476 {T(k, `◆${NB}Stats${NB}Claude${NB}Code`, { color: BRAND, bold: true })}
477 {T(
478 k,
479 m.loading
480 ? 'Premier relevé en cours : ccusage lit les journaux de Claude Code, puis le mod publie ceux de ce poste et ajoute ceux des autres postes.'
481 : 'Aucun relevé pour l’instant. Le relevé lance « npx ccusage@latest » (coût, tokens, sessions, blocs de 5 h), lit le cache de /stats et ajoute les usages des autres postes.',
482 { dim: true, wrap: 'wrap' },
483 )}
484 {m.error && <Box marginTop={1}>{T(k, m.error, { color: C.red, wrap: 'wrap' })}</Box>}
485 {!m.loading && (
486 <Box marginTop={1}>
487 <Button key="refresh" variant="primary" label={`▶${NB}Lancer${NB}le${NB}relevé`} onPress={() => act.refresh()} />
488 </Box>
489 )}
490 </Box>
491 )
492}
493
494const emptyCard = (k: Kit, act: Actions): unknown => {
495 const { Box, Button } = k
496 return (
497 <Box key="empty" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
498 {T(k, 'Rien à montrer pour ces filtres.', { bold: true })}
499 {T(k, 'Aucune consommation sur cette période, ce poste, ce projet ou ce modèle.', { dim: true, wrap: 'wrap' })}
500 <Box marginTop={1}>
501 <Button key="reset-filters" label={`↺${NB}Tout${NB}afficher`} onPress={() => act.setUi({ period: 'all', machine: 'all', project: null, model: null })} />
502 </Box>
503 </Box>
504 )
505}
506
507const PERIODS: [Period, string][] = [
508 ['all', 'Tout'],
509 ['90', `90${NB}j`],
510 ['30', `30${NB}j`],
511 ['7', `7${NB}j`],
512]
513/** Les bascules de poste : tous, puis chaque poste du relevé (celui-ci d'abord) ; rien avec un seul poste. */
514const machineToggles = (s: Snapshot): [Machine, string][] =>
515 s.machines.length > 1 ? [['all', `Tous${NB}les${NB}postes`], ...s.machines.map((m): [Machine, string] => [m, m.replace(/ /g, NB)])] : []
516
517/** Les données que seul ce poste connaît (cache de /stats, blocs de 5 h) valent pour la vue. */
518const isLocalView = (ctx: Ctx): boolean => ctx.f.machine === 'all' || ctx.f.machine === ctx.s.machine
519
520/** Le poste choisi n'a publié que des totaux par jour (l'ancien serveur) : pas de sessions. */
521const lacksSessions = (ctx: Ctx): boolean => ctx.f.machine !== 'all' && ctx.s.sessionless.includes(ctx.f.machine)
522
523/** Carte d'en-tête : titre, fraîcheur et ↻, période, machine, filtres, couverture, avertissements. */
524const header = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
525 const { Box, Button } = k
526 const s = ctx.s
527 const m = view.meta
528 const ui = ctx.ui
529 const status = m.loading ? T(k, `relevé${NB}en${NB}cours…`, { color: C.tangerine }) : T(k, `relevé${NB}${ago(s.fetchedAt, view.now)}`, { dim: true })
530 const days = daysBetween(ctx.from, ctx.today) + 1
531 const coverage = [
532 `${fmtDate(ctx.from, ctx.from.slice(0, 4) !== ctx.today.slice(0, 4))}${NB}→${NB}${fmtDate(ctx.today)}`,
533 plural(days, 'jour'),
534 s.version ? `ccusage${NB}${s.version}` : null,
535 s.isOffline ? `prix${NB}hors${NB}ligne` : null,
536 ]
537 .filter(Boolean)
538 .join(SEP)
539 const isHereOn = ctx.here !== null && ui.project === ctx.here
540 const hasHere = ctx.here !== null && s.rows.some((r) => groupOf(r.project, s.projects) === ctx.here)
541 const machines = machineToggles(s)
542 const filters = [
543 ui.project && !isHereOn && <Button key="clear-project" plain={true} label={`⌂${NB}${nameOf(ctx, ui.project)}${NB}✕`} onPress={() => act.setUi({ project: null })} />,
544 ui.model && <Button key="clear-model" plain={true} label={`◆${NB}${modelName(ui.model)}${NB}✕`} onPress={() => act.setUi({ model: null })} />,
545 ].filter(isShown)
546 const unpriced = s.unpriced.map(modelName)
547 return (
548 <Box key="head" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
549 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
550 <Box flexShrink={1}>{chip(k, `◆${NB}Stats${NB}Claude${NB}Code`, BRAND, { wrap: 'truncate-end' })}</Box>
551 <Box flexDirection="row" flexShrink={0} marginLeft={1} alignItems="center">
552 <Box marginRight={1}>{status}</Box>
553 <Button key="refresh" plain={true} label={`↻${NB}Actualiser`} onPress={() => act.refresh()} />
554 </Box>
555 </Box>
556 <Box marginTop={1}>
557 {line(
558 k,
559 [
560 ...PERIODS.map(([p, label]) => toggle(k, `period-${p}`, label, ui.period === p, () => act.setUi({ period: p }))),
561 machines.length > 0 && T(k, '│', { dim: true }),
562 ...machines.map(([x, label]) => toggle(k, `machine-${x}`, label, ctx.f.machine === x, () => act.setUi({ machine: x }))),
563 hasHere && T(k, '│', { dim: true }),
564 hasHere && toggle(k, 'here', `⌂${NB}Ce${NB}projet`, isHereOn, () => act.setUi({ project: isHereOn ? null : ctx.here })),
565 ],
566 true,
567 )}
568 </Box>
569 {filters.length > 0 && <Box marginTop={1}>{line(k, [T(k, 'Filtres', { dim: true }), ...filters], true)}</Box>}
570 <Box marginTop={1}>{T(k, coverage, { dim: true, wrap: 'truncate-end' })}</Box>
571 {unpriced.length > 0 && T(k, `⚠${NB}Sans prix connu chez ccusage, compté 0${NB}$ : ${unpriced.join(', ')}`, { color: C.amber, wrap: 'wrap' })}
572 {s.keptDays > 0 && T(k, `↺${NB}${plural(s.keptDays, 'jour gardé', 'jours gardés')} par le mod : Claude Code a effacé leurs journaux.`, { dim: true, wrap: 'wrap' })}
573 {s.warnings.map((w) => T(k, `⚠${NB}${w}`, { color: C.amber, wrap: 'wrap' }))}
574 </Box>
575 )
576}
577
578/** La zone d'activité, toujours juste sous l'en-tête : relevé en cours, dernier message. */
579const activity = (k: Kit, view: View, act: Actions): unknown => {
580 const { Box, Button } = k
581 const m = view.meta
582 const said = view.notice
583 const secs = m.startedAt ? Math.max(0, Math.round((view.now - m.startedAt) / 1000)) : 0
584 const rows = [
585 m.loading && line(k, [spinner(k, C.tangerine, view.now, 'relevé'), T(k, `${m.loading}${secs >= 2 ? ` (${secs}${NB}s)` : ''}`, { color: C.tangerine, wrap: 'truncate-end' })]),
586 said && (
587 <Box flexDirection="row" justifyContent="space-between">
588 <Box flexShrink={1}>{T(k, said.text, { color: TONE[said.tone], wrap: 'wrap' })}</Box>
589 {said.tone === 'error' && (
590 <Box flexShrink={0} marginLeft={1}>
591 <Button key="notice-dismiss" plain={true} dimColor={true} label="x" onPress={() => act.dismissNotice()} />
592 </Box>
593 )}
594 </Box>
595 ),
596 ].filter(isShown)
597 if (rows.length === 0) return null
598 return (
599 <Box key="activity" flexDirection="column" marginTop={1} paddingX={1}>
600 {rows}
601 </Box>
602 )
603}
604
605const tabsRow = (k: Kit, view: View, act: Actions): unknown => {
606 const { Box, Button } = k
607 return (
608 <Box key="tabs" flexDirection="row" flexWrap="wrap" marginTop={1}>
609 {TABS.map(([tab, label]) => (
610 <Box marginRight={2}>
611 <Button key={`tab-${tab}`} plain={true} dimColor={tab !== view.ui.tab} label={tab === view.ui.tab ? `▸${NB}${label}` : label} onPress={() => act.setUi({ tab })} />
612 </Box>
613 ))}
614 </Box>
615 )
616}
617
618// ─── Rythme horaire : le feu ──────────────────────────────────────────────
619
620/** Les couleurs des cinq niveaux (calme → en feu), dans l'ordre de `heatOf`. */
621const HEAT_COLORS = [C.teal, C.amber, C.tangerine, C.coral, C.red] as const
622
623/** Le feu d'un rythme horaire : une flamme SVG ; sur le terminal, quatre crans colorés et 🔥 au dernier niveau. */
624const flame = (k: Kit, perHour: number, height = 14): unknown => {
625 const heat = rateHeat(perHour)
626 if (k.isRich) return svgOf(k, flameSvg(heat.level, height), `Rythme ${fmtCostShort(perHour)}/h : ${heat.label}`)
627 const notches = [1, 2, 3, 4].map((i) => T(k, i <= heat.level ? '▰' : '▱', i <= heat.level ? { color: HEAT_COLORS[i] } : { color: C.gray, dim: true }))
628 return TT(k, [...notches, heat.level === 4 ? T(k, `${NB}🔥`) : ''])
629}
630
631/** « 48 $/h » à la couleur de son niveau, en gras dès « intense ». */
632const rateText = (k: Kit, perHour: number, withLabel = false): unknown => {
633 const heat = rateHeat(perHour)
634 return T(k, `${fmtCostShort(perHour)}/h${withLabel ? `${NB}·${NB}${heat.label}` : ''}`, { color: heat.color, bold: heat.level >= 3 })
635}
636
637/** La jauge du rythme : SVG en dégradé et seuils, ou cinq zones de blocs colorées sur le terminal. */
638const heatGauge = (k: Kit, perHour: number, cells: number): unknown => {
639 if (k.isRich) return svgOf(k, heatGaugeSvg({ perHour, thresholds: RATE_LEVELS, colors: HEAT_COLORS, width: px(cells) }), `Jauge du rythme : ${fmtCostShort(perHour)}/h, ${rateHeat(perHour).label}`)
640 const { Box } = k
641 const zone = Math.max(2, Math.floor(cells / 5))
642 const edges = [0, ...RATE_LEVELS, RATE_LEVELS[3] * 2]
643 const parts: unknown[] = []
644 for (let i = 0; i < 5; i++) {
645 const lo = edges[i]!
646 const hi = edges[i + 1]!
647 const fill = perHour >= hi ? zone : perHour <= lo ? 0 : Math.max(1, Math.round(((perHour - lo) / (hi - lo)) * zone))
648 if (fill > 0) parts.push(T(k, '█'.repeat(fill), { color: HEAT_COLORS[i] }))
649 if (zone - fill > 0) parts.push(T(k, '░'.repeat(zone - fill), { color: HEAT_COLORS[i], dim: true }))
650 }
651 const ticks = RATE_LEVELS.map((t) => String(t).padStart(zone, NB)).join('')
652 return (
653 <Box flexDirection="column">
654 <Box>{TT(k, parts)}</Box>
655 {T(k, ticks, { dim: true })}
656 </Box>
657 )
658}
659
660// ─── Discussion ouverte ───────────────────────────────────────────────────
661
662/**
663 * Les chiffres de la discussion ouverte, une source chacun : le coût du moteur (comme /cost) ;
664 * les tokens de ccusage (détail au dernier ↻, sinon la session du relevé), plus les tours finis
665 * depuis, et ceux des sous-agents de workflow qu'elle a lancés.
666 */
667export function figuresOf(view: View): { cost: number | null; tokens: number | null; own: SessionRow | null; children: SessionRow[] } {
668 const d = view.discussion
669 const s = view.snapshot
670 const own = (d.id && s?.sessions.find((x) => x.id === d.id)) || null
671 const children = d.id && s ? s.sessions.filter((x) => x.parent === d.id) : []
672 const base = d.detail ? { at: d.detail.fetchedAt, tokens: d.detail.tokens, cost: d.detail.cost } : own && s ? { at: s.fetchedAt, tokens: own.tokens, cost: own.cost } : null
673 const later = d.turns.filter((t) => !base || t.at > base.at).reduce((n, t) => n + t.tokens, 0)
674 const kids = children.reduce((n, x) => n + x.tokens, 0)
675 const tokens = base ? base.tokens + later + kids : d.turns.length ? later : null
676 const cost = d.live?.cost ?? (base ? base.cost + children.reduce((n, x) => n + x.cost, 0) : null)
677 return { cost, tokens, own, children }
678}
679
680const discussionTab = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
681 const { Box, Button } = k
682 const d = view.discussion
683 const live = d.live
684 const fig = figuresOf(view)
685 const tz = ctx.s.tzOffset
686 const metric = ctx.metric
687 const entries = d.detail?.entries ?? []
688 const costs = d.detail ? estimateCosts(entries, d.detail.cost, unitPricesOf(ctx.s.rows)) : []
689 const startedAt = live?.startedAt ?? entries[0]?.[0] ?? fig.own?.first ?? null
690 const elapsed = startedAt ? Math.max(0, view.now - startedAt) : 0
691 const project = fig.own ? groupOf(fig.own.project, ctx.s.projects) : ctx.here
692 const isToday = (ms: number) => dayOf(ms, tz) === ctx.today
693 const when = (ms: number) => (isToday(ms) ? hhmm(ms, tz) : `${fmtDate(dayOf(ms, tz))}${NB}${hhmm(ms, tz)}`)
694 // Rythme : la dernière heure d'appels (l'intensité du moment), et la moyenne depuis le début.
695 const hourAgo = view.now - 3_600_000
696 const lastHour = d.detail && view.now - d.detail.fetchedAt < 30 * 60_000 ? entries.reduce((n, e, i) => (e[0] > hourAgo ? n + (costs[i] ?? 0) : n), 0) : null
697 const average = fig.cost !== null && elapsed > 6 * 60_000 ? fig.cost / (elapsed / 3_600_000) : null
698 const totals = entries.reduce((t, e) => ({ input: t.input + e[2], output: t.output + e[3], cacheWrite: t.cacheWrite + e[4], cacheRead: t.cacheRead + e[5] }), { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 })
699 const cacheShare = totals.input + totals.cacheWrite + totals.cacheRead > 0 ? totals.cacheRead / (totals.input + totals.cacheWrite + totals.cacheRead) : null
700 const turns = live?.turns ?? 0
701
702 const status = d.loading
703 ? line(k, [spinner(k, C.tangerine, view.now, 'détail'), T(k, 'ccusage…', { color: C.tangerine })])
704 : d.detail
705 ? T(k, `détail${NB}${ago(d.detail.fetchedAt, view.now)}`, { dim: true })
706 : null
707 const head = (
708 <Box key="disc-head" flexDirection="column" borderStyle="round" borderColor={C.violet} paddingX={1} marginTop={1}>
709 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
710 <Box flexShrink={1}>{chip(k, `◉${NB}Cette${NB}discussion`, C.violet, { wrap: 'truncate-end' })}</Box>
711 <Box flexDirection="row" flexShrink={0} marginLeft={1} alignItems="center">
712 {status && <Box marginRight={1}>{status}</Box>}
713 <Button key="discussion-refresh" plain={true} label={`↻${NB}Discussion`} onPress={() => act.refreshDiscussion()} />
714 </Box>
715 </Box>
716 <Box marginTop={1}>
717 {T(
718 k,
719 [
720 project ? nameOf(ctx, project) : null,
721 live?.model ? modelName(live.model) : null,
722 turns ? plural(turns, 'tour') : null,
723 startedAt ? `commencée ${ago(startedAt, view.now)}` : null,
724 d.id ? `id ${d.id.slice(0, 8)}` : null,
725 ]
726 .filter(Boolean)
727 .join(SEP),
728 { dim: true, wrap: 'truncate-end' },
729 )}
730 </Box>
731 {T(k, 'Les filtres de période, de machine et de projet ne s’appliquent pas à cet onglet.', { dim: true, italic: true, wrap: 'wrap' })}
732 {d.error && T(k, d.error, { color: C.red, wrap: 'wrap' })}
733 </Box>
734 )
735
736 // La carte du coût : le moteur pour le chiffre, la courbe cumulée de l'estimation par appel.
737 let run = 0
738 const curve = costs.map((c) => (run += c))
739 const sub = [entries.length ? plural(entries.length, 'appel API', 'appels API') : null, turns && fig.cost !== null ? `moy. ${fmtCost(fig.cost / turns)} par tour` : null, live?.cost !== null && live ? 'coût du moteur (/cost)' : 'coût de ccusage']
740 .filter(Boolean)
741 .join(' · ')
742 const value = fig.cost !== null ? fmtCost(fig.cost) : '—'
743 const hero = k.isRich ? (
744 <Box key="disc-hero" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
745 {svgOf(k, heroSvg({ label: 'Coût de cette discussion', value, sub, delta: null, deltaColor: C.green, values: sampleLast(curve, 120), color: MONEY, width: px(ctx.inner) }), `Coût de cette discussion : ${value}. ${sub}`)}
746 </Box>
747 ) : (
748 <Box key="disc-hero" flexDirection="column" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
749 {T(k, 'COÛT DE CETTE DISCUSSION', { dim: true, bold: true })}
750 <Box marginTop={1}>{chip(k, value, MONEY)}</Box>
751 {T(k, sub, { dim: true, wrap: 'truncate-end' })}
752 {curve.length > 1 && <Box marginTop={1}>{columnsChart(k, 'discussion-curve', resample(costs, Math.max(8, ctx.inner - 2)), 3, MONEY)}</Box>}
753 </Box>
754 )
755
756 // Le rythme : la flamme, le chiffre, la jauge et ses niveaux.
757 const shown = lastHour ?? average
758 const rateCard =
759 shown !== null
760 ? card(k, 'disc-rate', 'Rythme', rateHeat(shown).color, T(k, lastHour !== null ? 'dernière heure' : 'moyenne depuis le début', { dim: true }), [
761 <Box flexDirection="row" alignItems="center" marginTop={1}>
762 <Box marginRight={1}>{flame(k, shown, 28)}</Box>
763 {rateText(k, shown, true)}
764 {lastHour !== null && average !== null && <Box marginLeft={2}>{T(k, `moyenne ${fmtCostShort(average)}/h`, { dim: true })}</Box>}
765 </Box>,
766 <Box marginTop={1}>{heatGauge(k, shown, Math.min(ctx.inner, 60))}</Box>,
767 say(k, heatLegend()),
768 ])
769 : null
770
771 const window = live?.window ?? null
772 const tiles: Tile[] = ([
773 fig.tokens !== null ? { key: 'd-tokens', label: 'Tokens', value: fmtTokens(fig.tokens), sub: entries.length ? `entrée ${fmtTokens(totals.input)} · sortie ${fmtTokens(totals.output)}` : 'au dernier relevé', color: BRAND } : null,
774 cacheShare !== null ? { key: 'd-cache', label: 'Lu en cache', value: fmtPct(cacheShare), sub: `lu ${fmtTokens(totals.cacheRead)} · écrit ${fmtTokens(totals.cacheWrite)}`, color: C.teal } : null,
775 live?.contextPercent !== null && live?.contextPercent !== undefined
776 ? { key: 'd-context', label: 'Contexte', value: fmtPct(live.contextPercent / 100), sub: live.contextTokens !== null && window ? `${fmtTokens(live.contextTokens)} sur ${fmtTokens(window)}` : 'de la fenêtre', color: live.contextPercent >= 80 ? C.red : live.contextPercent >= 60 ? C.amber : C.green }
777 : null,
778 startedAt ? { key: 'd-duration', label: 'Durée', value: fmtDuration(elapsed), sub: 'depuis le début', color: C.lilac } : null,
779 entries.length ? { key: 'd-calls', label: 'Appels API', value: fmtInt(entries.length), sub: turns ? `${fmtInt(Math.round(entries.length / turns))} par tour` : 'sur la discussion', color: C.indigo } : null,
780 fig.children.length ? { key: 'd-agents', label: 'Sous-agents', value: fmtInt(fig.children.length), sub: `${fmtCost(fig.children.reduce((n, x) => n + x.cost, 0))} · workflows`, color: C.pink } : null,
781 ] as (Tile | null)[]).filter((x): x is Tile => x !== null)
782
783 if (d.detail && entries.length === 0) {
784 return (
785 <Box key="discussion" flexDirection="column">
786 {head}
787 {hero}
788 {rateCard}
789 {tileGrid(k, tiles)}
790 {card(k, 'disc-none', 'Détail', C.violet, null, [say(k, 'Pas encore d’appel enregistré pour cette discussion : ↻ Discussion après le prochain tour.')])}
791 </Box>
792 )
793 }
794
795 if (!d.detail) {
796 return (
797 <Box key="discussion" flexDirection="column">
798 {head}
799 {hero}
800 {rateCard}
801 {tileGrid(k, tiles)}
802 {card(k, 'disc-empty', 'Détail', C.violet, null, [
803 say(k, d.loading ? 'ccusage lit la transcription de cette discussion…' : 'Le détail (frise, modèles, appels les plus chers) vient de ccusage, sur cette seule discussion.'),
804 !d.loading && (
805 <Box marginTop={1}>
806 <Button key="discussion-detail" variant="primary" label={`▶${NB}Détailler${NB}la${NB}discussion`} onPress={() => act.refreshDiscussion()} />
807 </Box>
808 ),
809 ])}
810 </Box>
811 )
812 }
813
814 // La frise : des seaux de 5 min à 2 j selon la durée, empilés par modèle, et le cumul.
815 const first = entries[0]?.[0] ?? view.now
816 const last = Math.max(entries[entries.length - 1]?.[0] ?? view.now, first + 60_000)
817 const step = bucketMinutes(first, last) * 60_000
818 const start = Math.floor(first / step) * step
819 const n = Math.max(1, Math.floor((last - start) / step) + 1)
820 const models = unique(entries.map((e) => e[1]))
821 const byModel = new Map<string, { cost: number; tokens: number; calls: number }>(models.map((m) => [m, { cost: 0, tokens: 0, calls: 0 }]))
822 const buckets = Array.from({ length: n }, () => new Map<string, number>())
823 entries.forEach((e, i) => {
824 const v = metric === 'cost' ? (costs[i] ?? 0) : entryTokens(e)
825 const b = buckets[Math.min(n - 1, Math.floor((e[0] - start) / step))]!
826 b.set(e[1], (b.get(e[1]) ?? 0) + v)
827 const m = byModel.get(e[1])!
828 m.cost += costs[i] ?? 0
829 m.tokens += entryTokens(e)
830 m.calls += 1
831 })
832 const ranked = [...byModel.entries()].sort((a, b) => b[1].tokens - a[1].tokens)
833 const values = buckets.map((b) => [...b.values()].reduce((s, v) => s + v, 0))
834 let cum = 0
835 const cumulative = values.map((v) => (cum += v))
836 const isLong = last - first > 20 * 3_600_000
837 const label = (i: number) => {
838 const at = start + i * step
839 return isLong ? fmtDate(dayOf(at, tz)) : hhmm(at, tz)
840 }
841 const stepText = step >= 86_400_000 ? `${step / 86_400_000}${NB}j` : step >= 3_600_000 ? `${step / 3_600_000}${NB}h` : `${step / 60_000}${NB}min`
842 const chart = k.isRich
843 ? svgOf(
844 k,
845 stackSvg({
846 buckets: buckets.map((b, i) => ({ label: label(i), parts: ranked.map(([m]) => ({ value: b.get(m) ?? 0, color: ctx.colors[m] ?? C.gray })) })),
847 width: px(ctx.inner),
848 fmt: (v) => fmtMetric(v, metric, true),
849 cumulative: n > 1 ? { values: cumulative, color: C.coral, label: `cumul ${fmtMetric(cum, metric, true)}` } : null,
850 }),
851 `${metric === 'cost' ? 'Coût estimé' : 'Tokens'} par tranche de ${stepText}, empilé par modèle`,
852 )
853 : (() => {
854 const cell = n * 2 <= ctx.inner - 2 ? 2 : 1
855 const firstLabel = label(0)
856 const lastLabel = label(n - 1)
857 const stacks = buckets.map((b) => ranked.map(([m]) => ({ value: b.get(m) ?? 0, color: ctx.colors[m] ?? C.gray })))
858 return (
859 <Box flexDirection="column">
860 {T(k, `max${NB}${fmtMetric(Math.max(...values, 0), metric, true)}`, { dim: true })}
861 {columnsChart(k, 'discussion-chart', values, 6, MONEY, cell, -1, stacks)}
862 {T(k, `${firstLabel}${NB.repeat(Math.max(1, n * cell - firstLabel.length - lastLabel.length))}${lastLabel}`, { dim: true })}
863 </Box>
864 )
865 })()
866
867 const top = entries
868 .map((e, i) => ({ e, cost: costs[i] ?? 0 }))
869 .sort((a, b) => b.cost - a.cost)
870 .slice(0, 5)
871 const maxCost = top[0]?.cost ?? 0
872 const cells = Math.max(6, Math.min(18, ctx.inner - 50))
873
874 return (
875 <Box key="discussion" flexDirection="column">
876 {head}
877 {hero}
878 {rateCard}
879 {tileGrid(k, tiles)}
880 {card(k, 'disc-chart', `${metric === 'cost' ? 'Coût estimé' : 'Tokens'} par tranche de ${stepText}`, toneOf(metric), metricToggle(k, ctx, act), [
881 <Box marginTop={1}>{chart}</Box>,
882 <Box marginTop={1}>{line(k, ranked.map(([m]) => TT(k, [T(k, '●', { color: ctx.colors[m] ?? C.gray }), T(k, `${NB}${modelName(m)}`, { dim: true })])), true)}</Box>,
883 ])}
884 {card(k, 'disc-models', 'Modèles de la discussion', C.violet, null, [
885 ...ranked.map(([m, x], i) => (
886 <Box key={`dm-${m}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
887 <Box width={2} flexShrink={0}>
888 {T(k, '●', { color: ctx.colors[m] ?? C.gray })}
889 </Box>
890 <Box flexGrow={1} flexShrink={1}>
891 {TT(k, [T(k, modelName(m), { bold: true }), T(k, `${NB}${NB}${plural(x.calls, 'appel')}`, { dim: true })], { wrap: 'truncate-end' })}
892 </Box>
893 <Box flexShrink={0} marginLeft={1}>
894 {line(k, [
895 <Box width={9} justifyContent="flex-end">{T(k, fmtTokens(x.tokens), { color: BRAND })}</Box>,
896 <Box width={10} justifyContent="flex-end">{T(k, fmtCost(x.cost), { color: MONEY, bold: true })}</Box>,
897 ])}
898 </Box>
899 </Box>
900 )),
901 ])}
902 {card(k, 'disc-top', 'Appels les plus chers', MONEY, null, [
903 ...top.map(({ e, cost }, i) => (
904 <Box key={`dt-${i}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
905 <Box width={14} flexShrink={0}>
906 {T(k, when(e[0]), { dim: true })}
907 </Box>
908 <Box flexShrink={0} marginRight={1}>
909 {meter(k, [{ value: cost, color: MONEY }], maxCost, cells, fmtCost(cost))}
910 </Box>
911 <Box flexGrow={1} flexShrink={1}>
912 {TT(k, [T(k, fmtCost(cost), { color: MONEY, bold: true }), T(k, `${NB}${NB}${modelName(e[1])}${NB}·${NB}${fmtTokens(entryTokens(e))}`, { dim: true })], { wrap: 'truncate-end' })}
913 </Box>
914 </Box>
915 )),
916 ])}
917 {fig.children.length > 0 &&
918 card(k, 'disc-agents', 'Sous-agents de workflow', C.pink, null, [
919 ...fig.children.slice(0, 8).map((x, i) => (
920 <Box key={`da-${x.id}`} flexDirection="row" marginTop={i === 0 ? 1 : 0}>
921 <Box flexGrow={1} flexShrink={1}>
922 {T(k, `${when(x.first)}${NB}·${NB}${fmtDuration(x.last - x.first)}`, { dim: true, wrap: 'truncate-end' })}
923 </Box>
924 {line(k, [T(k, fmtTokens(x.tokens), { color: BRAND }), T(k, fmtCost(x.cost), { color: MONEY, bold: true })])}
925 </Box>
926 )),
927 ])}
928 {say(k, 'Coût en tête : celui du moteur (/cost). Tokens et détail : ccusage, au dernier ↻ Discussion. Le coût par appel est estimé : ccusage ne donne que le total, réparti selon les tokens de chaque appel et le barème de son modèle.')}
929 </Box>
930 )
931}
932
933// ─── Journée ──────────────────────────────────────────────────────────────
934
935/** Plafond de la liste des discussions du jour ; les suivantes sont additionnées sur une ligne. */
936const DAY_DISCUSSIONS_CAP = 8
937
938/** Une ligne de classement : marque, nom, jauge, valeur, part ; un détail atténué dessous. */
939const shareRow = (k: Kit, o: { key: string; mark: unknown; name: unknown; value: number; max: number; share: number; metric: Metric; cells: number; detail: string | null; isFirst: boolean }): unknown => {
940 const { Box } = k
941 const color = toneOf(o.metric)
942 return (
943 <Box key={o.key} flexDirection="column" marginTop={o.isFirst ? 1 : 0}>
944 <Box flexDirection="row" alignItems="center">
945 <Box width={2} flexShrink={0}>
946 {o.mark}
947 </Box>
948 <Box flexGrow={1} flexShrink={1}>
949 {o.name}
950 </Box>
951 <Box flexShrink={0} marginLeft={1}>
952 {line(k, [
953 meter(k, [{ value: o.value, color }], o.max, o.cells, `${fmtPct(o.share)} du jour`),
954 <Box width={10} justifyContent="flex-end">
955 {T(k, fmtMetric(o.value, o.metric), { color, bold: true })}
956 </Box>,
957 <Box width={6} justifyContent="flex-end">
958 {T(k, fmtPct(o.share), { dim: true })}
959 </Box>,
960 ])}
961 </Box>
962 </Box>
963 {o.detail && <Box marginLeft={2}>{T(k, o.detail, { dim: true, wrap: 'truncate-end' })}</Box>}
964 </Box>
965 )
966}
967
968/**
969 * Le choix du jour : la veille et le lendemain, le jour actif d'avant et d'après (sous les
970 * filtres), et le retour au jour du relevé. Rien avant le premier jour connu ni après le relevé.
971 */
972const dayNav = (k: Kit, choice: ReturnType<typeof dayChoice>, today: string, act: Actions): unknown => {
973 const { Box, Button } = k
974 const { day, first, latest, prevActive, nextActive } = choice
975 // Le jour du relevé s'enregistre comme null : un relevé plus récent le fait suivre.
976 const go = (to: string) => act.setUi({ day: to === latest ? null : to })
977 const dateOf = (d: string) => fmtDate(d, d.slice(0, 4) !== today.slice(0, 4))
978 return (
979 <Box key="day-nav" marginTop={1}>
980 {line(
981 k,
982 [
983 // « veille » toujours en premier : des clics répétés ne tombent jamais sur un autre bouton.
984 day > first && <Button key="day-prev" plain={true} label={`◂${NB}veille`} onPress={() => go(addDays(day, -1))} />,
985 chip(k, day === today ? `aujourd’hui,${NB}${fmtDay(day)}` : fmtDay(day), MONEY),
986 day < latest && <Button key="day-next" plain={true} label={`lendemain${NB}▸`} onPress={() => go(addDays(day, 1))} />,
987 // Le jour actif d'avant ou d'après, quand ce n'est pas déjà la veille ou le lendemain.
988 prevActive && prevActive !== addDays(day, -1) && <Button key="day-prev-active" plain={true} dimColor={true} label={`◂◂${NB}${dateOf(prevActive)}`} onPress={() => go(prevActive)} />,
989 nextActive && nextActive !== addDays(day, 1) && <Button key="day-next-active" plain={true} dimColor={true} label={`${dateOf(nextActive)}${NB}▸▸`} onPress={() => go(nextActive)} />,
990 day < latest && <Button key="day-latest" plain={true} dimColor={true} label={`↺${NB}${latest === today ? 'aujourd’hui' : fmtDay(latest)}`} onPress={() => act.setUi({ day: null })} />,
991 ],
992 true,
993 )}
994 </Box>
995 )
996}
997
998/**
999 * Le récap d'une journée : par défaut celle du relevé, comme le bandeau (aujourd'hui, ou le jour
1000 * d'un relevé plus ancien) ; un jour passé se choisit en tête de l'onglet. Les filtres de poste,
1001 * de projet et de modèle s'appliquent ; la période, non.
1002 */
1003const dayTab = (k: Kit, view: View, ctx: Ctx, act: Actions): unknown => {
1004 const { Box } = k
1005 const s = ctx.s
1006 const tz = s.tzOffset
1007 const metric = ctx.metric
1008 // Tous les jours, sous les filtres de poste, de projet et de modèle : le jour et ses 30 jours de comparaison.
1009 const scope: Filter = { ...ctx.f, from: null }
1010 const all = selectRows(s.rows, scope, s.projects)
1011 const fetchedDay = dayOf(s.fetchedAt, tz)
1012 const choice = dayChoice(ctx.ui.day ?? null, fetchedDay, firstDate(s.rows), activeDates(all))
1013 const day = choice.day
1014 const isToday = day === ctx.today
1015 const dayText = isToday ? 'aujourd’hui' : `le${NB}${fmtDay(day)}`
1016 const rows = all.filter((r) => r.date === day)
1017 const isFiltered = ctx.f.machine !== 'all' || ctx.f.project !== null || ctx.f.model !== null
1018 const notes = [
1019 dayNav(k, choice, ctx.today, act),
1020 // Un relevé d'un jour passé : seulement quand on regarde ce jour-là sans l'avoir choisi.
1021 fetchedDay !== ctx.today && day === fetchedDay && say(k, `Relevé du ${fmtDay(fetchedDay)} : ↻${NB}Actualiser pour la journée en cours.`, { color: C.amber }),
1022 ctx.ui.period !== 'all' && say(k, 'La période ne s’applique pas à cet onglet, qui montre une journée ; les filtres de poste, de projet et de modèle, si.', { dim: true, italic: true }),
1023 ].filter(isShown)
1024 if (rows.length === 0) {
1025 return (
1026 <Box key="day" flexDirection="column">
1027 {notes}
1028 {card(k, 'day-empty', cap(isToday ? 'aujourd’hui' : fmtDay(day)), MONEY, null, [say(k, `Aucune consommation ${dayText}${isFiltered ? ' pour ces filtres' : ''}`.replace(/\.?$/, '.'))])}
1029 </Box>
1030 )
1031 }
1032
1033 const t = sum(rows)
1034 const cmp = compareDay(all, day, 'cost')
1035 const discussions = lacksSessions(ctx) ? [] : discussionsOfDay(selectSessions(s.sessions, scope, s.projects, tz), day, tz)
1036 const agents = discussions.reduce((n, d) => n + d.agents, 0)
1037
1038 // La carte du coût : le chiffre du jour, l'écart avec la veille, la courbe des 30 jours qui finissent ce jour-là.
1039 const label = isToday ? 'Coût d’aujourd’hui' : `Coût du ${fmtDay(day)}`
1040 const delta = cmp.previous > 0 ? (t.cost - cmp.previous) / cmp.previous : null
1041 const deltaText = delta !== null ? `${fmtDelta(delta)} vs la veille` : null
1042 const deltaColor = delta !== null && delta > 0 ? C.coral : C.green
1043 const sub = [`${fmtTokens(t.tokens)} tokens`, discussions.length ? plural(discussions.length, 'discussion') : null, `30${NB}j jusqu’au ${fmtDate(day)}`].filter(Boolean).join(' · ')
1044 const curve = seriesOf(all, 'day', addDays(day, -29), day).map((b) => b.total.cost)
1045 const hero = k.isRich ? (
1046 <Box key="day-hero" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
1047 {svgOf(k, heroSvg({ label, value: fmtCost(t.cost), sub, delta: deltaText, deltaColor, values: curve, color: MONEY, width: px(ctx.inner) }), `${label} : ${fmtCost(t.cost)}${deltaText ? `, ${deltaText}` : ''}. ${sub}`)}
1048 </Box>
1049 ) : (
1050 <Box key="day-hero" flexDirection="column" borderStyle="round" borderColor={MONEY} paddingX={1} marginTop={1}>
1051 {T(k, label.toUpperCase(), { dim: true, bold: true })}
1052 <Box marginTop={1}>{line(k, [chip(k, fmtCost(t.cost), MONEY), deltaText && T(k, deltaText, { color: deltaColor })])}</Box>
1053 {T(k, sub, { dim: true, wrap: 'truncate-end' })}
1054 <Box marginTop={1}>{columnsChart(k, 'day-curve', curve, 3, MONEY, 1, curve.length - 1)}</Box>
1055 </Box>
1056 )
1057
1058 const lead = modelsOf(rows, 'cost')[0]
1059 // Le cache de /stats a en général un jour de retard : ses messages seulement s'il compte déjà ce jour-là.
1060 const acts = s.activity && isLocalView(ctx) && !ctx.f.project && !ctx.f.model ? (s.activity.days.find((d) => d.date === day) ?? null) : null
1061 const tiles: Tile[] = ([
1062 { key: 'day-tokens', label: 'Tokens', value: fmtTokens(t.tokens), sub: `entrée ${fmtTokens(t.input)} · sortie ${fmtTokens(t.output)}`, color: BRAND },
1063 { key: 'day-cache', label: 'Lu en cache', value: fmtPct(cacheRatio(t)), sub: `lu ${fmtTokens(t.cacheRead)} · écrit ${fmtTokens(t.cacheWrite)}`, color: C.teal },
1064 { key: 'day-previous', label: 'La veille', value: fmtCost(cmp.previous), sub: fmtDay(addDays(day, -1)), color: C.slate },
1065 cmp.average !== null ? { key: 'day-average', label: 'Moyenne sur 30 j', value: fmtCost(cmp.average), sub: `par jour actif · écart ${fmtDelta((t.cost - cmp.average) / cmp.average)}`, color: C.lilac } : null,
1066 cmp.rank !== null && cmp.activeDays > 1 ? { key: 'day-rank', label: 'Rang sur 30 j', value: cmp.rank === 1 ? 'record' : `n°${NB}${cmp.rank}`, sub: `sur ${plural(cmp.activeDays, 'jour actif', 'jours actifs')}`, color: cmp.rank === 1 ? C.coral : MONEY } : null,
1067 !lacksSessions(ctx) ? { key: 'day-discussions', label: 'Discussions', value: fmtInt(discussions.length), sub: agents ? `avec ${plural(agents, 'sous-agent')}` : 'actives ce jour-là', color: C.violet } : null,
1068 lead ? { key: 'day-model', label: 'Modèle principal', value: modelName(lead.model), sub: `${fmtPct(lead.share)} du coût`, color: ctx.colors[lead.model] ?? BRAND } : null,
1069 acts ? { key: 'day-messages', label: 'Messages (ce poste)', value: fmtInt(acts.messages), sub: `${fmtInt(acts.tools)} appels d’outils`, color: C.indigo } : null,
1070 ] as (Tile | null)[]).filter((x): x is Tile => x !== null)
1071
1072 const cells = Math.max(6, Math.min(22, ctx.inner - 46))
1073 const total = valueOf(t, metric)
1074 // Une seule bascule coût / tokens, sur la carte des projets : les autres cartes disent leur mesure.
1075 const unitText = T(k, metric === 'cost' ? 'en coût' : 'en tokens', { dim: true })
1076
1077 // Par poste : seulement quand plusieurs postes ont travaillé ce jour-là.
1078 const machines = ctx.f.machine === 'all' ? s.machines.map((m) => ({ m, total: sum(rows.filter((r) => r.machine === m)) })).filter((x) => valueOf(x.total, metric) > 0) : []
1079 const machinesCard =
1080 machines.length > 1
1081 ? card(k, 'day-machines', 'Par poste', toneOf(metric), unitText, [
1082 ...machines.map((x, i) =>
1083 shareRow(k, {
1084 key: `day-machine-${x.m}`,
1085 mark: T(k, x.m === s.machine ? '●' : '○', { color: x.m === s.machine ? BRAND : C.gray }),
1086 name: TT(k, [T(k, x.m.replace(/ /g, NB), { bold: true }), x.m === s.machine ? T(k, `${NB}${NB}ce poste`, { color: BRAND }) : ''], { wrap: 'truncate-end' }),
1087 value: valueOf(x.total, metric),
1088 max: valueOf(machines[0]!.total, metric),
1089 share: total > 0 ? valueOf(x.total, metric) / total : 0,
1090 metric,
1091 cells,
1092 detail: null,
1093 isFirst: i === 0,
1094 }),
1095 ),
1096 say(k, 'Les autres postes comptent jusqu’à leur dernière publication dans claude-usage.'),
1097 ])
1098 : null
1099
1100 const projects = projectsOf(rows, [], s.projects, metric)
1101 const projectsCard = card(k, 'day-projects', 'Projets du jour', toneOf(metric), metricToggle(k, ctx, act), [
1102 ...projects.slice(0, PROJECTS_CAP).map((p, i) => {
1103 const isHere = p.key === ctx.here
1104 const count = discussions.filter((d) => groupOf(d.project, s.projects) === p.key).length
1105 const detail = [count ? plural(count, 'discussion') : null, elsewhere(ctx, p.byMachine, metric)].filter(Boolean).join(SEP)
1106 return shareRow(k, {
1107 key: `day-project-${p.key}`,
1108 mark: T(k, isHere ? '●' : '○', { color: isHere ? BRAND : C.gray, bold: isHere }),
1109 name: TT(k, [T(k, nameOf(ctx, p.key), { bold: true }), isHere ? T(k, `${NB}${NB}ce projet`, { color: BRAND }) : ''], { wrap: 'truncate-end' }),
1110 value: valueOf(p.total, metric),
1111 max: projects.length ? valueOf(projects[0]!.total, metric) : 0,
1112 share: p.share,
1113 metric,
1114 cells,
1115 detail: detail || null,
1116 isFirst: i === 0,
1117 })
1118 }),
1119 projects.length > PROJECTS_CAP && say(k, `… ${plural(projects.length - PROJECTS_CAP, 'autre projet', 'autres projets')} : ${fmtMetric(projects.slice(PROJECTS_CAP).reduce((n, p) => n + valueOf(p.total, metric), 0), metric)}.`),
1120 ])
1121
1122 // Les discussions : une heure ce jour-là, une date sinon (commencée avant, ou finie après).
1123 const at = (ms: number) => (dayOf(ms, tz) === day ? hhmm(ms, tz) : fmtDate(dayOf(ms, tz)))
1124 const isMulti = ctx.f.machine === 'all' && s.machines.length > 1
1125 const rest = discussions.slice(DAY_DISCUSSIONS_CAP)
1126 const discussionsCard = lacksSessions(ctx)
1127 ? card(k, 'day-discussions', 'Discussions du jour', C.violet, null, [say(k, `${ctx.f.machine} n’a publié que des totaux par jour : pas de détail des discussions.`)])
1128 : discussions.length > 0
1129 ? card(k, 'day-discussions', 'Discussions du jour', C.violet, T(k, plural(discussions.length, 'discussion'), { dim: true }), [
1130 ...discussions.slice(0, DAY_DISCUSSIONS_CAP).map((d, i) => (
1131 <Box key={`day-disc-${d.id}`} flexDirection="row" alignItems="center" marginTop={i === 0 ? 1 : 0}>
1132 <Box width={14} flexShrink={0}>
1133 {T(k, `${at(d.first)}–${at(d.last)}`, { dim: true, wrap: 'truncate-end' })}
1134 </Box>
1135 <Box flexGrow={1} flexShrink={1}>
1136 {TT(
1137 k,
1138 [
1139 T(k, nameOf(ctx, groupOf(d.project, s.projects)), { bold: true }),
1140 d.id === view.discussion.id ? T(k, `${NB}${NB}celle-ci`, { color: C.violet }) : '',
1141 isMulti ? T(k, `${NB}·${NB}${d.machine}`, { dim: true }) : '',
1142 d.agents ? T(k, `${NB}·${NB}+${plural(d.agents, 'sous-agent')}`, { dim: true }) : '',
1143 ],
1144 { wrap: 'truncate-end' },
1145 )}
1146 </Box>
1147 <Box flexShrink={0} marginLeft={1}>
1148 {/* Largeurs fixes : modèles, tokens et coûts en colonnes. */}
1149 {line(k, [
1150 ctx.inner >= 50 && <Box width={4}>{modelDots(k, ctx, d.models)}</Box>,
1151 ctx.inner >= 70 && <Box width={8} justifyContent="flex-end">{T(k, fmtTokens(d.tokens), { color: BRAND, dim: true })}</Box>,
1152 <Box width={9} justifyContent="flex-end">{T(k, fmtCost(d.cost), { color: MONEY, bold: true })}</Box>,
1153 ])}
1154 </Box>
1155 </Box>
1156 )),
1157 rest.length > 0 && say(k, `… ${plural(rest.length, 'autre discussion', 'autres discussions')} : ${fmtCost(rest.reduce((n, d) => n + d.cost, 0))}.`),
1158 say(k, 'Coût de chaque discussion : son total, sous-agents et jours précédents compris (ccusage ne le découpe pas par jour). D’où un total qui peut dépasser celui du jour.'),
1159 ])
1160 : null
1161
1162 const models = modelsOf(rows, metric)
1163 const modelsCard = card(k, 'day-models', 'Modèles du jour', toneOf(metric), unitText, [
1164 ...models.map((m, i) =>
1165 shareRow(k, {
1166 key: `day-model-${m.model}`,
1167 mark: T(k, '●', { color: ctx.colors[m.model] ?? C.gray }),
1168 name: TT(k, [T(k, modelName(m.model), { bold: true }), T(k, `${NB}${NB}${metric === 'cost' ? `${fmtTokens(m.total.tokens)}${NB}tokens` : fmtCost(m.total.cost)}`, { dim: true })], { wrap: 'truncate-end' }),
1169 value: valueOf(m.total, metric),
1170 max: models.length ? valueOf(models[0]!.total, metric) : 0,
1171 share: m.share,
1172 metric,
1173 cells,
1174 detail: null,
1175 isFirst: i === 0,
1176 }),
1177 ),
1178 ])
1179
1180 // Les blocs de 5 h du jour : ce poste seul, tous projets (ccusage ne les sépare pas).
1181 const dayBlocks = isLocalView(ctx) ? blocksOfDay(s.blocks, day, tz) : []
1182 const blocksCard =
1183 dayBlocks.length > 0
1184 ? card(k, 'day-blocks', 'Blocs de 5 h', C.tangerine, T(k, `ce${NB}poste, tous projets`, { dim: true }), [
1185 ...dayBlocks.map((b, i) => {
1186 const rate = blockRate(b)
1187 const isActive = b.isActive && b.end > ctx.now
1188 return (
1189 <Box key={`day-block-${b.start}`} marginTop={i === 0 ? 1 : 0}>
1190 {line(
1191 k,
1192 [
1193 T(k, `${hhmm(b.start, tz)}–${hhmm(b.end, tz)}`, isActive ? { color: C.tangerine, bold: true } : { dim: true }),
1194 T(k, fmtCost(b.cost), { color: MONEY, bold: true }),
1195 T(k, `${fmtTokens(b.tokens)}${NB}tokens`, { color: BRAND }),
1196 rate !== null && line(k, [flame(k, rate, 14), rateText(k, rate, true)]),
1197 isActive && T(k, `en${NB}cours, reste${NB}${fmtDuration(b.end - ctx.now)}`, { color: C.tangerine }),
1198 isActive && b.projectedCost !== null && T(k, `projection${NB}${fmtCost(b.projectedCost)}`, { dim: true }),
1199 modelDots(k, ctx, b.models),
1200 ],hooks/raster.ts 151 lines1// Graphiques du terminal en grille de cases (`Raster`) : une case = un caractère, une couleur
2// de texte et une couleur de fond, empaquetés selon `RasterProps` (triplets u32 little-endian
3// `[point de code, avant-plan, fond]`, rangée par rangée, en base64 standard). Une seule
4// feuille par graphique, au lieu d'un Text par série de cases. Fonctions pures : la vue
5// (view.tsx) les pose dans l'arbre, seulement quand la table de la surface a `Raster`.
6
7import { C } from './model.ts'
8import type { BarPart, HeatCell } from './svg.ts'
9
10/** La couleur par défaut du terminal (bit 24 seul). */
11export const DEFAULT = 0x01000000
12/** Les niveaux du calendrier, comme les opacités du SVG (heatmapSvg). */
13export const HEAT_OPACITY = [0.14, 0.32, 0.52, 0.76, 1] as const
14
15const SPACE = 0x20
16const FULL = 0x2588
17/** ▁ à █ : U+2581 à U+2588. */
18const eighth = (fill: number): number => 0x2580 + Math.max(1, Math.min(8, fill))
19
20export type RasterCells = { columns: number; rows: number; cells: string }
21type Cell = readonly [codePoint: number, foreground: number, background: number]
22
23/** `#RRGGBB` en `0x00RRGGBB`. */
24export const rgb = (hex: string): number => {
25 const n = Number.parseInt(hex.replace('#', '').slice(0, 6), 16)
26 return Number.isFinite(n) ? n & 0xffffff : DEFAULT
27}
28
29/**
30 * `color` posé à l'opacité `alpha` sur un fond foncé (C.ink). Le terminal n'a pas de
31 * transparence : les niveaux du SVG deviennent des teintes pleines.
32 */
33export const tint = (hex: string, alpha: number): number => {
34 const a = rgb(C.ink)
35 const b = rgb(hex)
36 const ch = (shift: number): number => {
37 const x = (a >> shift) & 0xff
38 const y = (b >> shift) & 0xff
39 return Math.round(x + (y - x) * alpha) & 0xff
40 }
41 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
42}
43
44const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
45
46/** Base64 standard avec bourrage, écrit à la main (pas de `toBase64` ni de `btoa` supposés). */
47const base64 = (bytes: Uint8Array): string => {
48 let out = ''
49 for (let i = 0; i < bytes.length; i += 3) {
50 const a = bytes[i]!
51 const b = i + 1 < bytes.length ? bytes[i + 1]! : 0
52 const c = i + 2 < bytes.length ? bytes[i + 2]! : 0
53 out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
54 out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)]! : '='
55 out += i + 2 < bytes.length ? B64[c & 63]! : '='
56 }
57 return out
58}
59
60/** Empaquette une grille rangée par rangée ; toutes les rangées ont `columns` cases. */
61export const pack = (grid: readonly (readonly Cell[])[]): RasterCells => {
62 const rows = grid.length
63 const columns = grid[0]?.length ?? 0
64 const bytes = new Uint8Array(columns * rows * 12)
65 const view = new DataView(bytes.buffer)
66 let at = 0
67 for (const row of grid) {
68 for (let x = 0; x < columns; x++) {
69 const cell = row[x] ?? [SPACE, DEFAULT, DEFAULT]
70 view.setUint32(at, cell[0], true)
71 view.setUint32(at + 4, cell[1] >>> 0, true)
72 view.setUint32(at + 8, cell[2] >>> 0, true)
73 at += 12
74 }
75 }
76 return { columns, rows, cells: base64(bytes) }
77}
78
79/**
80 * Le calendrier : 7 rangées (lundi en haut), une semaine sur `cell` colonnes (la dernière vide
81 * quand `cell` > 1, comme les glyphes), chaque jour une case pleine à la teinte de son niveau
82 * (niveau 0 en gris). Les jours hors période restent vides.
83 */
84export const heatCells = (weeks: readonly HeatCell[][], color: string, cell: number): RasterCells => {
85 const shades = HEAT_OPACITY.map((o, i) => (i === 0 ? tint(C.gray, 0.45) : tint(color, o)))
86 const grid: Cell[][] = []
87 for (let d = 0; d < 7; d++) {
88 const row: Cell[] = []
89 for (const week of weeks) {
90 const day = week[d]
91 for (let j = 0; j < cell; j++) row.push(day && (j < cell - 1 || cell === 1) ? [FULL, shades[day.level] ?? shades[4]!, DEFAULT] : [SPACE, DEFAULT, DEFAULT])
92 }
93 grid.push(row)
94 }
95 return pack(grid)
96}
97
98/** La légende du calendrier : les cinq teintes, une case chacune. */
99export const heatLegendCells = (color: string): RasterCells =>
100 pack([HEAT_OPACITY.map((o, i) => [FULL, i === 0 ? tint(C.gray, 0.45) : tint(color, o), DEFAULT] as Cell)])
101
102/**
103 * Colonnes sur `height` rangées, en huitièmes de case ; `cell` cases par colonne (la dernière
104 * vide quand `cell` > 1). Avec `stacks` (les parts de chaque colonne, du bas vers le haut),
105 * une case pleine coupée par deux parts prend la part du bas en avant-plan et celle du haut en
106 * fond : la frontière entre modèles tombe au huitième près.
107 */
108export const columnsCells = (
109 values: readonly number[],
110 height: number,
111 color: string,
112 cell = 1,
113 highlight = -1,
114 stacks: readonly (readonly BarPart[])[] | null = null,
115): RasterCells => {
116 const max = Math.max(...values, 1e-9)
117 const tall = values.map((v) => (v > 0 ? Math.max(1, Math.round((v / max) * height * 8)) : 0))
118 // La couleur du huitième `at8` (compté depuis le bas) de la colonne `i`.
119 const colorAt = (i: number, at8: number): string => {
120 const parts = stacks?.[i]
121 const all = values[i] ?? 0
122 if (!parts || all <= 0) return i === highlight ? C.coral : color
123 let top = 0
124 for (const p of parts) {
125 top += (Math.max(0, p.value) / all) * (tall[i] ?? 0)
126 if (at8 < top) return p.color
127 }
128 return parts[parts.length - 1]?.color ?? color
129 }
130 const grid: Cell[][] = []
131 for (let r = height - 1; r >= 0; r--) {
132 const row: Cell[] = []
133 tall.forEach((h8, i) => {
134 const fill = Math.max(0, Math.min(8, h8 - r * 8))
135 let glyph: Cell = [SPACE, DEFAULT, DEFAULT]
136 if (fill === 8) {
137 // Case pleine : une frontière entre deux parts devient un huitième bicolore.
138 const low = colorAt(i, r * 8 + 0.5)
139 let cut = 0
140 for (let j = 1; j < 8 && !cut; j++) if (colorAt(i, r * 8 + j + 0.5) !== low) cut = j
141 glyph = cut ? [eighth(cut), rgb(low), rgb(colorAt(i, r * 8 + cut + 0.5))] : [FULL, rgb(low), DEFAULT]
142 } else if (fill > 0) {
143 glyph = [eighth(fill), rgb(colorAt(i, r * 8 + fill / 2)), DEFAULT]
144 }
145 for (let j = 0; j < cell; j++) row.push(j < cell - 1 || cell === 1 ? glyph : [SPACE, DEFAULT, DEFAULT])
146 })
147 grid.push(row)
148 }
149 return pack(grid)
150}
151hooks/svg.ts 410 lines1// Graphiques SVG des surfaces distantes (desktop, VS Code, mobile). La base (échappement,
2// barre segmentée, anneau, indicateur) est copiée de git-board (hooks/svg.ts) ; les
3// graphiques de statistiques suivent.
4//
5// - Les dessins sont des images (jamais `isInteractive`, qui dessine dans un cadre à fond
6// blanc rechargé à chaque redessin).
7// - Rien qui change chaque seconde dans un SVG : un SVG identique n'est pas rechargé.
8// - Couleurs mi-tons et gris neutre, opacités plutôt que teintes claires ou foncées :
9// lisibles sur fond clair comme sombre.
10// - Chaque SVG reste sous 131 072 caractères et compte dans les 100 000 du panneau : valeurs
11// arrondies au dixième, pas d'attribut inutile.
12
13const FONT = `font-family="'Segoe UI', system-ui, -apple-system, sans-serif"`
14const MUTED = '#8B8B93'
15const WHITE = '#FFFFFF'
16
17export const esc = (text: string): string =>
18 text.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ?? c)
19
20const svg = (width: number, height: number, body: string): string =>
21 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
22
23const r1 = (n: number): number => Math.round(n * 10) / 10
24
25export type Drawn = { source: string; width: number; height: number }
26
27/** Un plafond rond pour un axe : 1, 2, 2,5 ou 5 fois une puissance de dix. */
28export function niceMax(value: number): number {
29 if (!(value > 0)) return 1
30 const pow = 10 ** Math.floor(Math.log10(value))
31 for (const m of [1, 2, 2.5, 5, 10]) if (m * pow >= value) return m * pow
32 return 10 * pow
33}
34
35// ─── Base copiée de git-board ───────────────────────────────────────────────
36
37export type BarPart = { value: number; color: string }
38
39/** Barre segmentée arrondie. */
40export const barSvg = (parts: readonly BarPart[], total: number, width: number, height = 9): Drawn => {
41 const w = Math.max(40, Math.round(width))
42 const sum = Math.max(total, parts.reduce((n, p) => n + p.value, 0), 1e-9)
43 let x = 0
44 const segs: string[] = []
45 for (const part of parts) {
46 if (part.value <= 0) continue
47 const sw = (part.value / sum) * w
48 segs.push(`<rect x="${r1(x)}" width="${r1(Math.max(1, sw))}" height="${height}" fill="${part.color}"/>`)
49 x += sw
50 }
51 const body =
52 `<defs><clipPath id="b"><rect width="${w}" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
53 `<g clip-path="url(#b)"><rect width="${w}" height="${height}" fill="${MUTED}" fill-opacity="0.2"/>${segs.join('')}</g>`
54 return { source: svg(w, height, body), width: w, height }
55}
56
57/** Anneau de progression, le pourcentage au centre. */
58export const ringSvg = (ratio: number, color: string, size = 56, label?: string): Drawn => {
59 const r = size / 2 - 6
60 const c = 2 * Math.PI * r
61 const value = Math.min(1, Math.max(0, ratio))
62 const mid = size / 2
63 const body =
64 `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="6"/>` +
65 (value > 0
66 ? `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${color}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${r1(c * value)} ${r1(c)}" transform="rotate(-90 ${mid} ${mid})"/>`
67 : '') +
68 `<text x="${mid}" y="${mid + 4.5}" text-anchor="middle" ${FONT} font-size="13" font-weight="700" fill="${color}">${esc(label ?? `${Math.round(value * 100)}%`)}</text>`
69 return { source: svg(size, size, body), width: size, height: size }
70}
71
72/** Un arc qui tourne : un relevé en cours. */
73export const spinnerSvg = (color: string, size = 12): Drawn => {
74 const m = size / 2
75 const r = m - 2
76 const body =
77 `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2"/>` +
78 `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${r1(r * 2)} ${r1(r * 5)}"><animateTransform attributeName="transform" type="rotate" from="0 ${m} ${m}" to="360 ${m} ${m}" dur="0.9s" repeatCount="indefinite"/></circle>`
79 return { source: svg(size, size, body), width: size, height: size }
80}
81
82// ─── Courbes ────────────────────────────────────────────────────────────────
83
84/** Les points d'une courbe dans un cadre ; une valeur seule devient un plateau. */
85const points = (values: readonly number[], x0: number, x1: number, y0: number, y1: number): [number, number][] => {
86 const max = Math.max(...values, 1e-9)
87 const n = values.length
88 return values.map((v, i) => [r1(n > 1 ? x0 + (i * (x1 - x0)) / (n - 1) : (x0 + x1) / 2), r1(y1 - (v / max) * (y1 - y0))])
89}
90
91const area = (pts: readonly [number, number][], base: number, color: string, id: string): string => {
92 if (pts.length === 0) return ''
93 const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('')
94 const first = pts[0]!
95 const last = pts[pts.length - 1]!
96 return (
97 `<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity="0.38"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
98 `<path d="${line}L${last[0]} ${base}L${first[0]} ${base}Z" fill="url(#${id})"/>` +
99 `<path d="${line}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` +
100 `<circle cx="${last[0]}" cy="${last[1]}" r="3.2" fill="${color}"/>`
101 )
102}
103
104/** Courbe compacte d'une tuile. */
105export const sparkSvg = (values: readonly number[], color: string, width: number, height = 26): Drawn => {
106 const w = Math.max(40, Math.round(width))
107 const body = values.length > 1 ? area(points(values, 3, w - 4, 4, height - 2), height, color, 's') : ''
108 return { source: svg(w, height, body), width: w, height }
109}
110
111/**
112 * La carte de tête : un libellé, un grand chiffre, sa variation et une ligne de détail à
113 * gauche ; la courbe de la période à droite, quand la place le permet.
114 */
115export function heroSvg(o: { label: string; value: string; sub: string; delta: string | null; deltaColor: string; values: readonly number[]; color: string; width: number }): Drawn {
116 const w = Math.max(220, Math.round(o.width))
117 const h = 86
118 const valueW = o.value.length * 17.5
119 const hasCurve = w >= 380 && o.values.length > 1
120 const text =
121 `<text x="2" y="16" ${FONT} font-size="10.5" font-weight="700" letter-spacing="0.8" fill="${MUTED}">${esc(o.label.toUpperCase())}</text>` +
122 `<text x="0" y="52" ${FONT} font-size="32" font-weight="700" fill="${o.color}">${esc(o.value)}</text>` +
123 (o.delta ? `<text x="${r1(valueW + 10)}" y="50" ${FONT} font-size="12.5" font-weight="700" fill="${o.deltaColor}">${esc(o.delta)}</text>` : '') +
124 `<text x="2" y="76" ${FONT} font-size="11.5" fill="${MUTED}">${esc(o.sub)}</text>`
125 const curve = hasCurve ? area(points(o.values, w * 0.55, w - 6, 10, 70), 78, o.color, 'h') : ''
126 return { source: svg(w, h, curve + text), width: w, height: h }
127}
128
129// ─── Calendrier d'activité ──────────────────────────────────────────────────
130
131export type HeatCell = { date: string; level: number; isToday: boolean } | null
132
133/**
134 * Le calendrier : une colonne par semaine (lundi en haut), une case par jour, cinq
135 * niveaux d'intensité. `weeks[i][d]` est null hors de la période.
136 */
137export function heatmapSvg(weeks: readonly HeatCell[][], months: readonly { index: number; label: string }[], color: string, width: number): Drawn {
138 const n = Math.max(1, weeks.length)
139 const left = 22
140 const top = 16
141 const step = Math.max(7, Math.min(16, Math.floor((Math.max(160, width) - left - 4) / n)))
142 const size = step - 3
143 const w = left + n * step + 2
144 const h = top + 7 * step + 22
145 const OPACITY = [0.14, 0.32, 0.52, 0.76, 1]
146 const cells: string[] = []
147 weeks.forEach((week, i) => {
148 week.forEach((cell, d) => {
149 if (!cell) return
150 const x = left + i * step
151 const y = top + d * step
152 const fill = cell.level === 0 ? MUTED : color
153 cells.push(
154 `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2.5" fill="${fill}" fill-opacity="${OPACITY[cell.level] ?? 1}"${cell.isToday ? ` stroke="${color}" stroke-width="1.6"` : ''}/>`,
155 )
156 })
157 })
158 const labels = months
159 .filter((m, i, all) => i === 0 || m.index - all[i - 1]!.index >= 3)
160 .map((m) => `<text x="${left + m.index * step}" y="11" ${FONT} font-size="10" fill="${MUTED}">${esc(m.label)}</text>`)
161 const days = ['L', '', 'M', '', 'V', '', '']
162 .map((d, i) => (d ? `<text x="2" y="${top + i * step + size - 1}" ${FONT} font-size="9.5" fill="${MUTED}">${d}</text>` : ''))
163 .join('')
164 const ly = top + 7 * step + 8
165 const lx = w - 5 * (size + 3) - 44
166 const legend =
167 `<text x="${lx - 4}" y="${ly + size - 1}" text-anchor="end" ${FONT} font-size="9.5" fill="${MUTED}">Moins</text>` +
168 OPACITY.map((o, i) => `<rect x="${lx + i * (size + 3)}" y="${ly}" width="${size}" height="${size}" rx="2.5" fill="${i === 0 ? MUTED : color}" fill-opacity="${o}"/>`).join('') +
169 `<text x="${lx + 5 * (size + 3) + 2}" y="${ly + size - 1}" ${FONT} font-size="9.5" fill="${MUTED}">Plus</text>`
170 return { source: svg(w, h, labels.join('') + days + cells.join('') + legend), width: w, height: h }
171}
172
173// ─── Barres empilées ────────────────────────────────────────────────────────
174
175export type StackBucket = { label: string; parts: readonly BarPart[] }
176
177/**
178 * Barres empilées (une couleur par modèle) avec un axe gradué et, en option, la courbe du
179 * cumul sur sa propre échelle, sa valeur finale au bout.
180 */
181export function stackSvg(o: {
182 buckets: readonly StackBucket[]
183 width: number
184 height?: number
185 fmt: (v: number) => string
186 cumulative?: { values: readonly number[]; color: string; label: string } | null
187 /** Plafond imposé de l'axe (1 pour des parts) ; absent, un plafond rond au-dessus du maximum. */
188 max?: number
189}): Drawn {
190 const w = Math.max(240, Math.round(o.width))
191 const h = o.height ?? 168
192 const left = 44
193 const right = o.cumulative ? 58 : 8
194 const top = 10
195 const bottom = 18
196 const pw = w - left - right
197 const ph = h - top - bottom
198 const n = Math.max(1, o.buckets.length)
199 const totals = o.buckets.map((b) => b.parts.reduce((s, p) => s + Math.max(0, p.value), 0))
200 const max = o.max ?? niceMax(Math.max(...totals, 0))
201 const slot = pw / n
202 const bw = Math.max(1, Math.min(22, slot * 0.72))
203 const parts: string[] = []
204 // Graduations : 0, la moitié et le plafond.
205 for (const f of [0, 0.5, 1]) {
206 const y = r1(top + ph - f * ph)
207 parts.push(`<line x1="${left}" y1="${y}" x2="${left + pw}" y2="${y}" stroke="${MUTED}" stroke-opacity="${f === 0 ? 0.45 : 0.22}" stroke-dasharray="${f === 0 ? '' : '3 4'}"/>`)
208 parts.push(`<text x="${left - 6}" y="${r1(y + 3.5)}" text-anchor="end" ${FONT} font-size="10" fill="${MUTED}">${esc(o.fmt(max * f))}</text>`)
209 }
210 o.buckets.forEach((b, i) => {
211 let y = top + ph
212 const x = r1(left + i * slot + (slot - bw) / 2)
213 for (const p of b.parts) {
214 if (p.value <= 0) continue
215 const bh = Math.min(y - top, (p.value / max) * ph)
216 y -= bh
217 parts.push(`<rect x="${x}" y="${r1(y)}" width="${r1(bw)}" height="${r1(Math.max(0.6, bh))}" fill="${p.color}"/>`)
218 }
219 })
220 // Libellés de l'axe des x : six au plus, le premier et le dernier compris.
221 const every = Math.max(1, Math.ceil(n / 6))
222 o.buckets.forEach((b, i) => {
223 if (i % every !== 0 && i !== n - 1) return
224 if (i !== n - 1 && n - 1 - i < every / 2) return
225 const x = r1(left + i * slot + slot / 2)
226 const anchor = i === 0 && slot < 30 ? 'start' : i === n - 1 && slot < 30 ? 'end' : 'middle'
227 parts.push(`<text x="${anchor === 'start' ? left : anchor === 'end' ? left + pw : x}" y="${h - 4}" text-anchor="${anchor}" ${FONT} font-size="10" fill="${MUTED}">${esc(b.label)}</text>`)
228 })
229 if (o.cumulative && o.cumulative.values.length > 1) {
230 const vals = o.cumulative.values
231 const cmax = Math.max(...vals, 1e-9)
232 const pts = vals.map((v, i) => [r1(left + i * slot + slot / 2), r1(top + ph - (v / cmax) * ph)] as const)
233 const last = pts[pts.length - 1]!
234 parts.push(`<polyline points="${pts.map((p) => p.join(',')).join(' ')}" fill="none" stroke="${o.cumulative.color}" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round"/>`)
235 parts.push(`<circle cx="${last[0]}" cy="${last[1]}" r="3.4" fill="${o.cumulative.color}"/>`)
236 parts.push(`<text x="${r1(last[0] + 7)}" y="${r1(Math.max(top + 9, last[1] + 4))}" ${FONT} font-size="10.5" font-weight="700" fill="${o.cumulative.color}">${esc(o.cumulative.label)}</text>`)
237 }
238 return { source: svg(w, h, parts.join('')), width: w, height: h }
239}
240
241// ─── Anneau de répartition ──────────────────────────────────────────────────
242
243/** Un anneau en parts (un modèle par part), un chiffre et un mot au centre. */
244export function donutSvg(parts: readonly BarPart[], size: number, center: string, sub: string, color: string): Drawn {
245 const s = Math.max(90, Math.round(size))
246 const mid = s / 2
247 const r = mid - 10
248 const c = 2 * Math.PI * r
249 const total = parts.reduce((n, p) => n + Math.max(0, p.value), 0)
250 const gap = parts.filter((p) => p.value > 0).length > 1 ? 1.6 : 0
251 let at = 0
252 const arcs: string[] = [`<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.18" stroke-width="13"/>`]
253 if (total > 0) {
254 for (const p of parts) {
255 if (p.value <= 0) continue
256 const len = (p.value / total) * c
257 const shown = Math.max(0.8, len - gap)
258 arcs.push(
259 `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${p.color}" stroke-width="13" stroke-dasharray="${r1(shown)} ${r1(c)}" stroke-dashoffset="${r1(-at)}" transform="rotate(-90 ${mid} ${mid})"/>`,
260 )
261 at += len
262 }
263 }
264 const body =
265 arcs.join('') +
266 `<text x="${mid}" y="${mid + 2}" text-anchor="middle" ${FONT} font-size="${center.length > 7 ? 14 : 17}" font-weight="700" fill="${color}">${esc(center)}</text>` +
267 `<text x="${mid}" y="${mid + 17}" text-anchor="middle" ${FONT} font-size="10" fill="${MUTED}">${esc(sub)}</text>`
268 return { source: svg(s, s, body), width: s, height: s }
269}
270
271// ─── Colonnes ───────────────────────────────────────────────────────────────
272
273/**
274 * Des colonnes simples (heures, jours de la semaine, blocs de 5 h) : la plus haute porte sa
275 * valeur et sa couleur pleine, les autres sont adoucies ; `labels` vides sautés.
276 */
277export function columnsSvg(o: { values: readonly number[]; labels: readonly string[]; color: string; width: number; height?: number; fmt: (v: number) => string; highlight?: number }): Drawn {
278 const w = Math.max(160, Math.round(o.width))
279 const h = o.height ?? 112
280 const top = 16
281 const bottom = 16
282 const ph = h - top - bottom
283 const n = Math.max(1, o.values.length)
284 const slot = (w - 4) / n
285 const bw = Math.max(2, Math.min(34, slot * 0.66))
286 const max = Math.max(...o.values, 1e-9)
287 const peak = o.highlight ?? o.values.indexOf(Math.max(...o.values))
288 const parts: string[] = [`<line x1="2" y1="${top + ph + 0.5}" x2="${w - 2}" y2="${top + ph + 0.5}" stroke="${MUTED}" stroke-opacity="0.4"/>`]
289 o.values.forEach((v, i) => {
290 const bh = v > 0 ? Math.max(2, (v / max) * ph) : 0
291 const x = r1(2 + i * slot + (slot - bw) / 2)
292 const isPeak = i === peak && v > 0
293 if (bh > 0) parts.push(`<rect x="${x}" y="${r1(top + ph - bh)}" width="${r1(bw)}" height="${r1(bh)}" rx="${r1(Math.min(3, bw / 3))}" fill="${o.color}" fill-opacity="${isPeak ? 1 : 0.5}"/>`)
294 if (isPeak) parts.push(`<text x="${r1(x + bw / 2)}" y="${r1(top + ph - bh - 4)}" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${o.color}">${esc(o.fmt(v))}</text>`)
295 const label = o.labels[i]
296 if (label) parts.push(`<text x="${r1(x + bw / 2)}" y="${h - 3}" text-anchor="middle" ${FONT} font-size="10" fill="${isPeak ? o.color : MUTED}"${isPeak ? ' font-weight="700"' : ''}>${esc(label)}</text>`)
297 })
298 return { source: svg(w, h, parts.join('')), width: w, height: h }
299}
300
301// ─── Bloc de 5 h ────────────────────────────────────────────────────────────
302
303/**
304 * Le bloc de facturation en cours : la piste de 5 h, la part écoulée pleine, la fin projetée
305 * en pointillé, l'heure de début, de fin et « maintenant ».
306 */
307export function blockSvg(o: { elapsed: number; startLabel: string; endLabel: string; nowLabel: string; color: string; width: number }): Drawn {
308 const w = Math.max(200, Math.round(o.width))
309 const h = 38
310 const x0 = 4
311 const x1 = w - 4
312 const ratio = Math.min(1, Math.max(0, o.elapsed))
313 const xn = r1(x0 + ratio * (x1 - x0))
314 const body =
315 `<rect x="${x0}" y="6" width="${x1 - x0}" height="9" rx="4.5" fill="${MUTED}" fill-opacity="0.2"/>` +
316 `<rect x="${x0}" y="6" width="${r1(Math.max(4.5, xn - x0))}" height="9" rx="4.5" fill="${o.color}"/>` +
317 `<line x1="${xn}" y1="2" x2="${xn}" y2="19" stroke="${o.color}" stroke-width="2" stroke-linecap="round"/>` +
318 `<text x="${x0}" y="33" ${FONT} font-size="10" fill="${MUTED}">${esc(o.startLabel)}</text>` +
319 `<text x="${x1}" y="33" text-anchor="end" ${FONT} font-size="10" fill="${MUTED}">${esc(o.endLabel)}</text>` +
320 (ratio > 0.12 && ratio < 0.88 ? `<text x="${xn}" y="33" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${o.color}">${esc(o.nowLabel)}</text>` : '')
321 return { source: svg(w, h, body), width: w, height: h }
322}
323
324// ─── Rythme horaire : flamme et jauge ───────────────────────────────────────
325
326/** Les teintes de chaque niveau, du bout de la flamme à sa base. */
327const FIRE: readonly { tip: string; base: string; scale: number }[] = [
328 { tip: MUTED, base: MUTED, scale: 0.62 },
329 { tip: '#F7D774', base: '#D9A520', scale: 0.72 },
330 { tip: '#FFC46B', base: '#E8913A', scale: 0.84 },
331 { tip: '#FFB07A', base: '#EF5B3C', scale: 0.94 },
332 { tip: '#FFE27A', base: '#E5383B', scale: 1 },
333]
334
335// Une flamme dans une boîte de 24 × 30 : le corps, puis son cœur clair.
336const FLAME = 'M12 1C13.6 6 19.5 9.4 19.5 17.5A7.5 7.5 0 0 1 4.5 17.5C4.5 12.8 7.6 10.6 8.8 7C9.9 9.6 10.9 10.3 11.5 11.2C12.6 8.1 12.6 4.6 12 1Z'
337const CORE = 'M12 13.2C13.1 15.7 15.6 16.8 15.6 19.8A3.6 3.6 0 0 1 8.4 19.8C8.4 17.8 9.9 16.7 10.4 15.2C10.9 16.2 11.4 16.5 11.8 16.8C12.2 15.6 12.3 14.4 12 13.2Z'
338
339const flameAt = (x: number, scale: number, level: number, id: string): string => {
340 const f = FIRE[level] ?? FIRE[0]!
341 const t = `translate(${r1(x + 12 * (1 - scale))} ${r1(30 * (1 - scale))}) scale(${scale})`
342 if (level === 0) return `<path d="${FLAME}" transform="${t}" fill="none" stroke="${MUTED}" stroke-opacity="0.7" stroke-width="1.8" stroke-linejoin="round"/>`
343 return (
344 `<path d="${FLAME}" transform="${t}" fill="url(#${id})"/>` +
345 (level >= 2 ? `<path d="${CORE}" transform="${t}" fill="#FFF3C4" fill-opacity="${level >= 3 ? 0.9 : 0.65}"/>` : '')
346 )
347}
348
349/**
350 * La flamme du rythme horaire : éteinte (contour gris) au calme, puis plus haute et plus rouge à
351 * chaque niveau ; « en feu », un halo et deux flammèches de part et d'autre.
352 */
353export function flameSvg(level: number, height = 16): Drawn {
354 const lv = Math.max(0, Math.min(4, Math.round(level)))
355 const f = FIRE[lv]!
356 const isWide = lv === 4
357 const vw = isWide ? 40 : 24
358 const grad = `<defs><linearGradient id="fl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${f.tip}"/><stop offset="1" stop-color="${f.base}"/></linearGradient></defs>`
359 const body = isWide
360 ? `<path d="${FLAME}" transform="translate(5 -2.2) scale(1.25)" fill="${f.base}" fill-opacity="0.22"/>` + flameAt(-1, 0.6, 3, 'fl') + flameAt(17, 0.6, 3, 'fl') + flameAt(8, 1, 4, 'fl')
361 : flameAt(0, f.scale, lv, 'fl')
362 const w = Math.round((height * vw) / 30)
363 return { source: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${vw} 30">${grad}${body}</svg>`, width: w, height }
364}
365
366/**
367 * La jauge du rythme : cinq zones de même largeur (calme → en feu) en dégradé, les seuils
368 * marqués, une flèche et la valeur au rythme courant.
369 */
370export function heatGaugeSvg(o: { perHour: number; thresholds: readonly number[]; colors: readonly string[]; width: number }): Drawn {
371 const w = Math.max(200, Math.round(o.width))
372 const h = 40
373 const x0 = 4
374 const x1 = w - 4
375 const zone = (x1 - x0) / 5
376 const edges = [0, ...o.thresholds, (o.thresholds[o.thresholds.length - 1] ?? 32) * 2]
377 // Position par zones : chaque niveau a la même place, quelle que soit sa plage en $/h.
378 let at = x1
379 for (let i = 0; i < 5; i++) {
380 const lo = edges[i]!
381 const hi = edges[i + 1]!
382 if (o.perHour < hi) {
383 at = x0 + zone * (i + Math.max(0, o.perHour - lo) / Math.max(1e-9, hi - lo))
384 break
385 }
386 }
387 const stops = o.colors.map((c, i) => `<stop offset="${r1((i + 0.5) / o.colors.length)}" stop-color="${c}"/>`).join('')
388 const ticks = o.thresholds
389 .map((t, i) => {
390 const x = r1(x0 + zone * (i + 1))
391 return `<line x1="${x}" y1="14" x2="${x}" y2="24" stroke="${MUTED}" stroke-opacity="0.6"/><text x="${x}" y="36" text-anchor="middle" ${FONT} font-size="9.5" fill="${MUTED}">${t}</text>`
392 })
393 .join('')
394 const ax = r1(at)
395 const body =
396 // Le dégradé sur toute la piste : une jauge peu remplie reste verte au lieu de résumer toutes les couleurs.
397 `<defs><linearGradient id="hg" gradientUnits="userSpaceOnUse" x1="${x0}" y1="0" x2="${x1}" y2="0">${stops}</linearGradient><clipPath id="hc"><rect x="${x0}" y="15" width="${x1 - x0}" height="8" rx="4"/></clipPath></defs>` +
398 `<rect x="${x0}" y="15" width="${x1 - x0}" height="8" rx="4" fill="url(#hg)" fill-opacity="0.28"/>` +
399 `<rect x="${x0}" y="15" width="${r1(Math.max(4, ax - x0))}" height="8" fill="url(#hg)" clip-path="url(#hc)"/>` +
400 ticks +
401 `<path d="M${ax} 13 l-4.5 -6 h9 z" fill="${o.colors[Math.min(o.colors.length - 1, Math.floor((at - x0) / zone))] ?? MUTED}"/>`
402 return { source: svg(w, h, body), width: w, height: h }
403}
404
405/** Point d'état plein. */
406export const dotSvg = (color: string, size = 10): Drawn => {
407 const m = size / 2
408 return { source: svg(size, size, `<circle cx="${m}" cy="${m}" r="${m - 1}" fill="${color}"/><circle cx="${m}" cy="${m}" r="${r1(m / 3)}" fill="${WHITE}" fill-opacity="0.35"/>`), width: size, height: size }
409}
410types/index.d.ts 203 lines1// Contrat de l'état de stats-board : ce que l'hôte garde pour la session (`$.state`). Le
2// dernier relevé (`Snapshot`) est aussi gardé dans `$.store`, d'une session à l'autre.
3
4/** Une ligne de consommation : un jour, un projet, un modèle (sommes de ccusage). */
5export type UsageRow = {
6 /** Jour local, AAAA-MM-JJ. */
7 date: string
8 /** Dossier du projet tel que Claude Code l'encode (`C--Users-moi-projet`). */
9 project: string
10 /** Le poste qui l'a relevée : son nom réglé (Portable, Mac, Serveur…), ou « Ce poste » sans nom. */
11 machine: string
12 /** Modèle tel que ccusage le nomme (`claude-opus-5-5`). */
13 model: string
14 input: number
15 output: number
16 cacheWrite: number
17 cacheRead: number
18 /** Coût équivalent API, en dollars. */
19 cost: number
20}
21
22/** Une conversation Claude Code d'un poste (`ccusage claude session`). */
23export type SessionRow = {
24 id: string
25 /** Le poste où elle a eu lieu. */
26 machine: string
27 project: string
28 /** Sous-agent de workflow : la discussion qui l'a lancé (son dossier est `<projet>\<parent>\subagents\…`). */
29 parent: string | null
30 /** Première et dernière activité, en ms. */
31 first: number
32 last: number
33 cost: number
34 tokens: number
35 output: number
36 models: string[]
37}
38
39/** Un bloc de facturation de 5 h (`ccusage claude blocks`), sans les creux. */
40export type BlockRow = {
41 start: number
42 end: number
43 /** Dernière activité du bloc, en ms. */
44 lastAt: number
45 isActive: boolean
46 cost: number
47 tokens: number
48 models: string[]
49 /** Bloc en cours : rythme et projection de ccusage ; null ailleurs. */
50 costPerHour: number | null
51 projectedCost: number | null
52 remainingMinutes: number | null
53}
54
55export type ActivityDay = { date: string; messages: number; sessions: number; tools: number }
56
57/** Le cache de /stats de Claude Code (`stats-cache.json`), pour ce poste seulement. */
58export type Activity = {
59 /** Dernier jour compté : le cache a en général un jour de retard. */
60 through: string | null
61 days: ActivityDay[]
62 /** Sessions commencées par heure locale, de 0 à 23. */
63 hours: number[]
64 sessions: number
65 messages: number
66 longest: { ms: number; messages: number; at: string } | null
67 since: string | null
68}
69
70/**
71 * Le nom lisible d'un projet et son dossier, lus dans les transcriptions de Claude Code ; `label`, son nom
72 * d'affichage choisi dans `names.json` du dépôt partagé (jamais publié : `name` sert au rapprochement des postes).
73 */
74export type ProjectInfo = { name: string; path: string | null; label?: string }
75
76export type Snapshot = {
77 /** Format du relevé : un relevé gardé d'un autre format est écarté et refait. */
78 schema: number
79 /** Fin du dernier relevé, en ms. */
80 fetchedAt: number
81 /** Décalage horaire local, en minutes (comme `Date#getTimezoneOffset`). */
82 tzOffset: number
83 rows: UsageRow[]
84 sessions: SessionRow[]
85 blocks: BlockRow[]
86 activity: Activity | null
87 projects: Record<string, ProjectInfo>
88 /** Modèles sans prix connu de ccusage : comptés 0 $. */
89 unpriced: string[]
90 /** Prix embarqués de ccusage : le barème en ligne n'a pas pu être lu. */
91 isOffline: boolean
92 /** Le nom de ce poste (`claude-usage/.machine`), ou « Ce poste » sans nom réglé. */
93 machine: string
94 /** Les postes présents dans le relevé, celui-ci d'abord. */
95 machines: string[]
96 /** Les postes qui n'ont publié aucune session (l'ancien serveur : des totaux par jour seulement). */
97 sessionless: string[]
98 /** Version de ccusage qui a fait le relevé. */
99 version: string | null
100 /** Jours gardés par le mod alors que Claude Code a effacé leurs journaux. */
101 keptDays: number
102 warnings: string[]
103}
104
105/**
106 * Le fichier qu'un poste publie dans le dépôt partagé `claude-usage` (`machines/<nom>.json`) :
107 * ses lignes, ses sessions et les projets qu'elles citent. Les autres postes le lisent.
108 */
109export type Published = {
110 schema: number
111 machine: string
112 /** Dernière publication dont les données ont changé, en ms. */
113 updatedAt: number
114 tzOffset: number
115 rows: UsageRow[]
116 sessions: SessionRow[]
117 projects: Record<string, ProjectInfo>
118}
119
120export type Tab = 'discussion' | 'day' | 'overview' | 'trend' | 'models' | 'projects' | 'sessions' | 'rhythm'
121export type Period = 'all' | '90' | '30' | '7'
122/** Le filtre de poste : `all`, ou le nom d'un poste. */
123export type Machine = 'all' | (string & {})
124export type Metric = 'cost' | 'tokens'
125export type Grain = 'day' | 'week' | 'month'
126export type SessionSort = 'cost' | 'recent' | 'long'
127
128/** Listes du panneau dépliées au-delà de leur plafond (« ▸ tout voir (N) »). */
129export type Expanded = { models: boolean; projects: boolean; sessions: boolean; days: boolean }
130
131/** Les choix de l'affichage : onglet, période, filtres, mesure. */
132export type Ui = {
133 tab: Tab
134 period: Period
135 machine: Machine
136 metric: Metric
137 grain: Grain
138 /** Filtre de projet (clé ccusage, ou `@brouillons`) ; null pour tous. */
139 project: string | null
140 /** Filtre de modèle ; null pour tous. */
141 model: string | null
142 sessionSort: SessionSort
143 expanded: Expanded
144 /** Jour choisi dans l'onglet Journée (AAAA-MM-JJ) ; null pour celui du relevé. */
145 day: string | null
146}
147
148export type Meta = {
149 /** Étape du relevé en cours ; null au repos. */
150 loading: string | null
151 error: string | null
152 /** Début du relevé en cours, en ms. */
153 startedAt: number | null
154}
155
156export type Notice = { text: string; tone: 'ok' | 'error' | 'info' } | null
157
158/** Un appel API de la discussion (`ccusage claude session --id`) : heure (ms), modèle, entrée, sortie, cache écrit, cache lu. */
159export type DiscussionEntry = [at: number, model: string, input: number, output: number, cacheWrite: number, cacheRead: number]
160
161/** Les tokens d'un tour fini de la conversation principale, d'après le moteur (`turn.complete`). */
162export type TurnTokens = { at: number; tokens: number }
163
164/** La discussion ouverte : chiffres du moteur en direct, détail de ccusage à la demande. */
165export type Discussion = {
166 /** Identifiant de la session (le `sessionId` de ccusage). */
167 id: string | null
168 /** D'après le moteur (`$.session.usage()`), relu à chaque tour ; null avant la première lecture. */
169 live: {
170 /** Coût en dollars, comme `/cost`. */
171 cost: number | null
172 contextPercent: number | null
173 contextTokens: number | null
174 window: number | null
175 turns: number
176 model: string | null
177 startedAt: number | null
178 at: number
179 } | null
180 /** Le détail de ccusage (`claude session --id`), au dernier ↻ ; null avant. */
181 detail: { fetchedAt: number; cost: number; tokens: number; entries: DiscussionEntry[] } | null
182 /** Tours finis depuis le chargement du mod, pour ajouter aux tokens d'un relevé plus ancien qu'eux. */
183 turns: TurnTokens[]
184 loading: boolean
185 error: string | null
186}
187
188declare module 'claude-code' {
189 interface PluginState {
190 'stats-board': {
191 snapshot: Snapshot | null
192 ui: Ui
193 meta: Meta
194 notice: Notice
195 /** Bandeau au-dessus du prompt masqué (`x` ou `/ccstats hide`), le temps de la session. */
196 isBandHidden: boolean
197 /** Clé ccusage du dossier de la session (« Ce projet »). */
198 here: string | null
199 discussion: Discussion
200 }
201 }
202}
203