Track the carbon footprint of your Claude Code sessions

claude-carbon is an open source Claude Code plugin that tracks the carbon footprint of your sessions: a live CO2 estimate computed locally from your token usage, in the terminal status line and in the Code tab of the Claude desktop app, next to the session's API-equivalent cost. In the desktop app, /carbon-panel opens a pane with a pixel molecule coloured by this session's CO2 against the median of your past sessions, your usage over the period you pick, its warming stripes, and a button for your yearly card.
/carbon-panel: a pane in the Claude desktop app with your CO2 molecule, your usage and the warming stripes of your sessions (in a terminal, the session in the band above the prompt). See the mod~/.claude transcripts/carbon-report (text), /carbon-card (PNG), /carbon-pr (footprint of a pull request)About the
$figure. It is an API equivalent: what your session would cost at Anthropic's pay-as-you-go API list prices. On a Pro or Max subscription you do not pay it; it shows the value of what you used, next to its CO2. See How it works.
Two ways, same result. Pick the one that fits you:
Already installed? See Updating.

macOS or Linux:
Install claude-carbon for me by running this command:
curl -fsSL https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.sh | bash
Windows:
Install claude-carbon for me by running this command in PowerShell:
irm https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.ps1 | iex
This session: 3.6 gCO₂ · $0.12 (Cette session : 3,6 gCO₂ · 0,12 $ in French). /carbon-report and /carbon-card work from there too.The $ is an API-equivalent cost, not what your subscription bills.
If the install stops
git, which the installer needs and a Mac without developer tools does not have yet. Click Install, wait until it finishes (a few minutes), then tell Claude: "Run the claude-carbon install again."sqlite3, and macOS 15 and later also have jq; on an older Mac the installer fetches the official jq itself, checked against its published checksum. On Windows the installer prints the winget command to use.The chat and Cowork tabs are not covered: their sessions run on Anthropic's servers, so nothing reaches your machine to count.

1. Install (or update):
macOS, Linux, WSL:
curl -fsSL https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.sh | bash
Windows (PowerShell), which needs Git for Windows and two packages first - see Windows:
irm https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.ps1 | iex
Or, on any platform with Node.js:
npx claude-carbon
Same command to install and to update to the latest version (all three run the same installer).
Installed from Anthropic's community marketplace and the plugin fails to load? The
co2-tracker@claude-communityentry is pinned to a release from August 2026 that current Claude Code rejects ("Duplicate hooks file detected"). Run/plugin uninstall co2-tracker@claude-community, then install with one of the commands above.
2. Restart Claude Code. Your CO2 appears in the status line:
claude-carbon ⌥ main | 🟢 Opus 5 ▓▓▓░░░░░░░ 35% | $0.50 · 65gCO₂ | Use 24% ↻13:00
Segments, left to right: project + git branch, model + context window %, API-equivalent session cost + CO2, 5h block usage % + reset time. A 🔥 prefix appears when the sustained burn rate would overshoot 100% of the limit by the end of the 5h block (after a 15 min grace window, only once usage reaches 15%).
Where the CO2 shows up. The status line is a feature of the Claude Code terminal UI. It is displayed only when you run
claudein a terminal, including the integrated terminal of VS Code or JetBrains. The IDE extensions' chat panel has no status line at all, so nothing is displayed there even though the hooks still run and your sessions are recorded:/carbon-reportcovers them. If you use the VS Code panel and want the live CO2, open a terminal in VS Code (`Ctrl+`) and runclaude` from there. The web app (claude.ai/code) runs neither the status line nor the hooks, so nothing is displayed or recorded there. The desktop app does not display the status line either; the plugin's mod draws the CO2 in its Code tab.
5h quota source. The percentage comes directly from Anthropic's /api/oauth/usage endpoint (the same data Claude Code displays in /usage). No heuristic, no token-limit file to seed. Two sources in order:
rate_limits.five_hour.used_percentage in the statusline JSON, that value is used straight away.GET https://api.anthropic.com/api/oauth/usage with the bearer token from macOS Keychain, CLAUDE_CODE_OAUTH_TOKEN, or ~/.claude/.credentials.json. Cached 60s in ~/.claude/claude-carbon/oauth-usage.json.Accurate on every plan, including Max 20x.
3. Use the slash commands:
/carbon-report - text report with totals, a car equivalent, this year's share of a 2 t per-person budget, top sessions/carbon-card - generate shareable PNG report cards (drawn with the Chrome or Edge already on your machine, see Dependencies; without one the card opens in your browser)/carbon-update - update to the latest version and re-price history (see Updating)<img src="docs/example-card.png" alt="claude-carbon summary card: 1190.3 kg of estimated CO2 from January to September 2026, a bar per month, then 25.3B tokens processed, 94% of them cache reads, a car equivalent, the API-price cost and a yearly projection" width="540">
Generate yours with /carbon-card in Claude Code. The summary and detailed PNGs land in ~/Downloads/claude-carbon/ (the Windows Downloads folder follows the system setting, so a OneDrive-redirected one is honoured) and the folder opens in your file manager, with the card selected on macOS. CLAUDE_CARBON_EXPORT_DIR picks another folder; CLAUDE_CARBON_NO_OPEN=1 skips the opening.
Without Playwright or Chromium there is no PNG and the command still finishes: the summary card is saved as an HTML page in the same folder and opens in your browser, scaled to fit the window, and a screenshot of it does the job. The token count on the card is every token Claude processed, cache reads included, as ccusage counts them; the grey line under it gives the share of cache reads. The card's footer says where to find the tool, claude-carbon sur GitHub / claude-carbon on GitHub, with the address below it.
From the 1st to the 10th of each month the status line ends with 📊 September: 84.2kgCO₂ · /carbon-card: the total of the month just closed, the bar the card draws for it, in the same format as the session figure. It is read from carbon.db once a day while the nudge shows, and kept in ~/.claude/claude-carbon/last-month-total. A month with fewer than 7 days of sessions gets no nudge, so a fresh install is not pushed to share a near-empty card. Generating a card clears it for the month; it goes quiet on its own after the 10th. CLAUDE_CARBON_CARD_NUDGE_UNTIL_DAY moves the cut-off, CLAUDE_CARBON_NO_CARD_NUDGE=1 removes the nudge. Nothing is posted anywhere: sharing stays a human decision.
# Since a specific date
bash ~/code/claude-carbon/scripts/generate-report.sh --since 2026-03-01
# All time
bash ~/code/claude-carbon/scripts/generate-report.sh --all
# A closed period (--until is an exclusive upper bound: this one stops at June 30th)
bash ~/code/claude-carbon/scripts/generate-report.sh --since 2026-01-01 --until 2026-07-01
curl -fsSL https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.sh | CLAUDE_CARBON_DIR=~/my-path/claude-carbon bash
The env var has to sit on the bash side of the pipe, not the curl side, or the installer never sees it.
If you run a second Claude Code environment out of its own config directory, e.g.
alias claude-work="CLAUDE_CONFIG_DIR=~/.claude-work claude"
install claude-carbon into that same directory by passing CLAUDE_CONFIG_DIR to the installer:
curl -fsSL https://raw.githubusercontent.com/gwittebolle/claude-carbon/main/install.sh | CLAUDE_CONFIG_DIR=~/.claude-work bash
The status line, the hooks, the database and the /carbon-* commands all live under that config dir, so each environment tracks its own sessions independently. When CLAUDE_CONFIG_DIR is unset everything falls back to ~/.claude as before.
git clone https://github.com/gwittebolle/claude-carbon.git ~/code/claude-carbon
bash ~/code/claude-carbon/scripts/setup.sh
bash ~/code/claude-carbon/scripts/configure-settings.sh
The second script merges the block below into ~/.claude/settings.json (additively: an existing status line or third-party hooks are left alone) and symlinks the /carbon-* commands. To wire it by hand instead, skip it and add:
{
"statusLine": {
"type": "command",
"command": "~/code/claude-carbon/scripts/statusline.sh"
},
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/code/claude-carbon/scripts/persist-session.sh"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/code/claude-carbon/scripts/persist-on-exit.sh"
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/code/claude-carbon/scripts/safety-rescan.sh"
}
]
}
]
}
}
The Stop hook records the session after each turn, and SessionEnd records it once more as it closes, so a last turn interrupted before Stop fired is still counted. The SessionStart one re-scans for sessions both missed (crash, kill) and drives the daily update check the status line reads; without it you are never told a new version exists.
Restart Claude Code.
claude-carbon measures one developer's sessions, locally. If the question comes from your CTO, a client RFP or a CSR committee, the same methodology exists as a hosted layer:
The only places the OSS points there are a one-line footer in /carbon-report and a small credit on the /carbon-card PNGs. No status-line promo, no email capture: nothing leaves your machine.
The number reviewers rarely see is what the PR cost to develop: your local Claude Code sessions while building the branch. /carbon-pr (or scripts/generate-pr-report.sh) sums the recorded sessions of the current branch and posts one sticky comment on its open PR, through your own gh auth. Run it after your push; running it again updates the same comment in place.
**Claude Code carbon report** · developing this PR
124 g CO2e · $3.00 · 180k tokens · 1.5M cache reads · 2 sessions
How the sessions are attributed: the Stop hook stores each session's git branch (read from the transcript), and the report selects project + branch. Sessions recorded before this column existed carry no branch; scripts/backfill.sh repairs them while their transcripts are still on disk (about 30 days). A session whose last folder is a subdirectory of the repo is stored under the subdirectory's name and stays out of the sum. A session that ends on another branch than it started is attributed to where it finished.
Turning it off is the default: nothing is posted unless you run it. --dry-run previews the comment, --pr <number> targets a specific PR of the repository (one opened from a fork is refused: run the command from the fork's clone instead), and deleting the comment on GitHub is the full cleanup.
Three data paths, two levels of accuracy:
| Script | Trigger | Data source | Subagents | Cache reads | Accuracy |
|---|---|---|---|---|---|
backfill.sh | Manual / setup | JSONL files | Included | Counted (8% energy) | Best estimate |
persist-session.sh | Stop + SessionEnd hooks | JSONL files | Included | Counted (8% energy) | Best estimate |
statusline.sh | Every turn (live) | carbon.db row | Included | Counted (8% energy) | One turn late |
backfill and persist-session parse the raw JSONL transcripts (main session, subagent files and the transcripts of the agents a workflow starts), applying per-model emission factors. They deduplicate assistant messages by (message.id, requestId), so resumed and compacted sessions are not double-counted (this matches ccusage; without it the token sum inflates roughly 3x). Each session stores its raw token breakdown (input, cache write, cache read, output), which feeds the SQLite database used by reports.
Cost is the theoretical API list value (pay-as-you-go), not your subscription price: input, output, cache write, and cache read (0.1x input) at current Anthropic rates, set in data/prices.json. Cache writes are billed per TTL tier, 1.25x input for the 5-minute tier and 2x for the 1-hour one; each session's split is read from its transcript rather than assumed (Claude Code writes at the 1-hour tier). On deduplicated data it matches ccusage.
statusline reads this session's row from carbon.db at each turn, so the CO2 it shows is the session total on the same basis as the reports, one turn behind at most. Before that row exists (first turn, or no database yet) it falls back to an estimate from context_window.total_input_tokens, which is the size of the current context rather than a running total: subagents are invisible to it and every compaction sends it back down. Either way the status line displays, it never feeds the reports.
Claude Code deletes JSONL transcripts after about 30 days, so the SQLite database is the durable record. The Stop and SessionEnd hooks capture each session before its transcript ages out, and a once-a-day background re-scan (SessionStart hook, safety-rescan.sh) catches any session they missed while its transcript still exists, including one whose transcript kept growing after its row was written. Because each row stores raw token counts, recompute.sh regenerates cost and CO2 from data/factors.json + data/prices.json at any time, with no transcript needed. When Anthropic changes a price or a factor is revised, edit the config and run:
bash scripts/recompute.sh
Sessions recorded since September 2026 are re-derived model by model, exactly. Older sessions without a per-model split keep their stored values; --include-unsplit re-derives them at their dominant model, an approximation (see METHODOLOGY.md, "Surviving the 30-day transcript purge").
| Command | What it does |
|---|---|
/carbon-report | Text report with totals, car equivalent, share of a 2 t budget, top sessions |
/carbon-card | Generate shareable PNG report cards |
/carbon-badge | Shields.io badge of your total footprint for your READMEs |
/carbon-pr | Post the dev footprint of the current branch on its PR |
/carbon-update | Update to the latest version and re-price history |
| Script | What it does |
|---|---|
setup.sh | Init database, backfill historical sessions, show total |
statusline.sh | Status line script (called automatically by Claude Code) |
persist-session.sh | Stop hook (saves session data after each turn) |
persist-on-exit.sh | SessionEnd hook (saves the session once more as it closes, in the background) |
safety-rescan.sh | SessionStart hook (throttled background re-scan, catches missed sessions) |
backfill.sh | Re-parse all historical JSONL transcripts (incl. subagents) |
recompute.sh | Re-derive cost/CO2 from stored tokens after a price/factor change (no transcripts needed) |
generate-report.sh | Export PNG report cards (CLI, with --since / --until / --all) |
generate-badge.sh | Print the shields.io badge markdown + URL (CLI) |
generate-pr-report.sh | Post the branch's dev footprint on its PR (CLI, --dry-run / --pr) |
Note: backfill now derives project names from the transcript's cwd (matching the live hook). Sessions backfilled before this change keep their old, possibly truncated names; delete those rows and re-run backfill.sh to normalize them.
/carbon-badge prints a ready-to-paste shields.io badge with your measured all-time footprint and the month it was taken, clickable to the methodology:
<sub>That badge is live, not a mockup: the author's own total, 1,208 sessions measured between 15 January 2026 and 16 September 2026. It is one developer's footprint, not the project's.</sub>
[](https://github.com/gwittebolle/claude-carbon/blob/main/METHODOLOGY.md)
The badge is a static image built from your local database, so the number is measured, not estimated on the fly, and nothing is sent anywhere. The month in the message says how old the figure is; re-run /carbon-badge whenever you want to refresh both.
It is one developer's total, so it belongs in your profile README or a personal project. In a team or organisation repository, reviewers will ask what produced the number and why it links out of the organisation, and they will be right to.
Numbers follow the locale the report uses (fr prints 12,4 kg, us and the world default print 12.4 kg); CLAUDE_CARBON_LOCALE forces a set.
The claude-carbon plugin also holds a Claude Code mod that shows the CO2 of each answer when it ends, computed locally with the same formula and factors as the status line, and the session's total with its API-price cost.
✻ Worked for 38s · done 10:32 PM 16 gCO₂. A line keeps its own answer's figure when it is drawn again (a resize, a scroll, ctrl+o); a line drawn for the first time later, such as one only ctrl+o shows, gets no figure.hooks/mod/register.tsx 1575 lines1// The claude-carbon mod: the CO2 of each Claude Code answer, at the moment the answer ends.
2//
3// Terminal: at the right end of the turn duration line ("✻ Worked for 4s · done 10:32 PM
4// ... 1,1 gCO₂"). Desktop app, where that line is not drawn: a line under the answer, for
5// unusual answers only. The session total, CO2 and API-price cost, is pinned under the
6// prompt, unless a status line already shows it on this surface (the claude-carbon
7// one, or showSessionTotal off).
8//
9// /carbon-panel opens a pane: the mascot, a pixel CO2 molecule coloured by this session against
10// the median of past sessions, which walks while Claude works, and the warming stripes
11// of past sessions, by session, week or month. The history comes from the file the Stop
12// hook writes next to carbon.db (lib/sessions.ts); the mod never opens the database.
13//
14// One request at a time through turn.step, the formulas, factors and prices of the bash
15// plugin (lib/co2.ts, lib/cost.ts). Writes no database, starts no process, calls no
16// network, never changes what Claude does.
17
18import { atom, read, update } from "claude-code";
19import type { EngineInterface, Register, RenderInputOf } from "claude-code";
20
21import type {
22 AnswerCo2Entry,
23 PaneBlocks,
24 PaneView,
25 SessionTotal,
26} from "../../types";
27import {
28 co2Grams,
29 family,
30 DEFAULT_FACTORS,
31 isExcluded,
32 parseFactors,
33} from "./lib/co2.ts";
34import type { Factors, Family } from "./lib/co2.ts";
35import { costUsd, parsePrices } from "./lib/cost.ts";
36import { DETAIL_SVG_HEIGHT, periodDetail, splitBarSvg, swatchSvg } from "./lib/detail.ts";
37import type { Prices } from "./lib/cost.ts";
38import {
39 answerLine,
40 builtByLabel,
41 REPO_URL,
42 repoLabel,
43 updateText,
44 forTeamLabel,
45 AUTHOR_URL,
46 datePlaceholder,
47 dateInvalid,
48 dayInputValue,
49 teamUrl,
50 apiValueNote,
51 blockTitle,
52 sessionStatsText,
53 changeValue,
54 formatGrams,
55 moneyValue,
56 tileLabel,
57 blockLabel,
58 detailAlt,
59 detailBarLabel,
60 detailCoverage,
61 detailLine,
62 METHODOLOGY_URL,
63 methodologyLabel,
64 detailHover,
65 detailTotalLine,
66 detailWaitText,
67 kindLabel,
68 buildAnswerCo2,
69 cardButtonLabel,
70 cardDraftBusy,
71 cardFillFailed,
72 cardReadyText,
73 changePercent,
74 colorNote,
75 commandDescription,
76 compareText,
77 costText,
78 currentLine,
79 dateLabel,
80 detectLang,
81 emptyRangeText,
82 carValue,
83 carTag,
84 fromLabel,
85 hasLangVariable,
86 isCarbonStatusLine,
87 languageSetting,
88 contextText,
89 moreAgentsText,
90 subagentsAlt,
91 subagentTitle,
92 mascotAlt,
93 scaleAlt,
94 noHistoryText,
95 otherProjectsLine,
96 paneHint,
97 paneTitle,
98 paneUnplaced,
99 projectBarLine,
100 pushHistory,
101 rangeLabel,
102 sessionLine,
103 showLabel,
104 stripesAlt,
105 stripesCaption,
106 stripesWaitText,
107 stripeTitle,
108 toLabel,
109 waitingText,
110 welcomeText,
111} from "./lib/format.ts";
112import type { Lang } from "./lib/format.ts";
113import { lineEntry } from "./lib/lines.ts";
114import {
115 CELL_COLUMNS,
116 CELL_ROWS,
117 classOf,
118 colorOf,
119 miniFaceFrame,
120 moodOf,
121 isStill,
122 faceAt,
123 REACT_MS,
124 SLEEP_AFTER_MS,
125 type Mood,
126 mascotCells,
127 mascotSvg,
128 scaleSvg,
129 SCALE_WIDTH,
130 SCALE_HEIGHT,
131 NEUTRAL,
132 IDLE_STEP_MS,
133 stepMs,
134 subagentsSvg,
135 STRIPES_SVG_HEIGHT,
136 stripesSvg,
137} from "./lib/mascot.ts";
138import type { Stripe } from "./lib/mascot.ts";
139import {
140 barMedian,
141 bars,
142 CURRENT_VERSION,
143 DEFAULT_VIEW,
144 folderName,
145 panelSite,
146 historyPath,
147 newerRelease,
148 updateFlagPath,
149 isView,
150 dayKey,
151 parseDay,
152 parseHistory,
153 pastSessions,
154 periodTotal,
155 previousTotal,
156 RANGES,
157 MIN_STRIPES,
158 colourMedian,
159 sessionMedian,
160 windowOf,
161} from "./lib/sessions.ts";
162import type { SessionRow } from "./lib/sessions.ts";
163import { carEquivalence, equivalenceSet } from "./lib/equiv.ts";
164import type { CarEquivalence } from "./lib/equiv.ts";
165
166// One entry per main-loop turn, oldest first, uncounted turns included (co2 null) so
167// a duration line never borrows another turn's figure (lib/lines.ts).
168const answers = atom(
169 { plugin: "claude-carbon", key: "answers" } as const,
170 [] as AnswerCo2Entry[],
171);
172const session = atom(
173 { plugin: "claude-carbon", key: "session" } as const,
174 { grams: 0, usd: 0, turns: 0, tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 } } as SessionTotal,
175);
176
177const working = atom(
178 { plugin: "claude-carbon", key: "working" } as const,
179 false,
180);
181// In a terminal, /carbon-panel shows the molecule in the band above the prompt: a docked
182// pane would take a whole column for four lines.
183const band = atom(
184 { plugin: "claude-carbon", key: "band" } as const,
185 false,
186);
187const paneView = atom(
188 { plugin: "claude-carbon", key: "view" } as const,
189 DEFAULT_VIEW as PaneView,
190);
191// What the pane shows under the stripes, besides the total: the person's choice
192const DEFAULT_BLOCKS: PaneBlocks = {
193 compare: true,
194 cost: true,
195 equivalence: true,
196 projects: false,
197 detail: false,
198};
199const BLOCKS = ["compare", "cost", "equivalence", "projects", "detail"] as const;
200const paneBlocks = atom(
201 { plugin: "claude-carbon", key: "blocks" } as const,
202 DEFAULT_BLOCKS,
203);
204
205const PANE_ID = "carbon";
206const COMMAND = "carbon-panel";
207// The pane's last range and step, and the blocks shown, kept for the next session
208const VIEW_KEY = "pane.view";
209const BLOCKS_KEY = "pane.blocks";
210// The one-time "/carbon-panel" hint, for someone welcomed before the pane existed
211const HINT_KEY = "pane.hinted";
212
213// The middle of the palette, for a walk with no colour to set its pace
214const NEUTRAL_CLASS = 3;
215// The pane's settings control: a gear (U+2699, text presentation)
216const GEAR = "\u2699";
217// The most stripes drawn, the most recent kept: in the desktop app few enough to stay
218// readable in a side pane (and far under the Svg's 131072 characters), in a terminal
219// what the line holds, up to a Raster's 512 columns.
220const DESKTOP_MAX_STRIPES = 180;
221// The subagent molecules shown under the big one before "+N"
222const MAX_AGENT_MOLECULES = 8;
223// How long the pane says that /carbon-card waits in the prompt
224const CARD_NOTE_MS = 10_000;
225
226// v2: the values of v1 counted background subagents in the answer that started them.
227// The v1 key is left as it is and never read again.
228const HISTORY_KEY = "history.v2";
229const KEEP_ANSWERS = 100;
230
231type History = {
232 values: number[];
233 welcomed: boolean;
234};
235
236// The answer being written: from the main loop's turn.start to its turn.complete.
237type OpenTurn = {
238 turnId: string;
239 grams: number;
240 subagentGrams: number;
241 // Subagents started in the foreground during this turn, and those that made a request
242 spawned: Set<string>;
243 stepped: Set<string>;
244};
245
246function openTurn(turnId: string): OpenTurn {
247 return {
248 turnId,
249 grams: 0,
250 subagentGrams: 0,
251 spawned: new Set(),
252 stepped: new Set(),
253 };
254}
255
256// $.store is shared by every session on the machine and has no conditional write: two
257// sessions ending a turn at once can lose one value of the history. The median tolerates it.
258async function loadHistory($: EngineInterface): Promise<History> {
259 const raw = (await $.store.get(HISTORY_KEY)) as Partial<History> | undefined;
260 const values = Array.isArray(raw?.values)
261 ? raw.values.filter((v): v is number => typeof v === "number" && v > 0)
262 : [];
263 return { values, welcomed: raw?.welcomed === true };
264}
265
266// No terminal draws this session: the duration line never shows here.
267function isRemoteOnly(surfaces: readonly string[]): boolean {
268 return surfaces.length > 0 && !surfaces.includes("terminal");
269}
270
271// /clear and /resume go on in another conversation: its total and per-answer figures start over.
272async function resetSessionState($: EngineInterface) {
273 await $.state.set(
274 { plugin: "claude-carbon", key: "session" },
275 { grams: 0, usd: 0, turns: 0, tokens: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 } },
276 );
277 await $.state.set({ plugin: "claude-carbon", key: "answers" }, []);
278 $.ui.status(undefined);
279 pane.subagents = 0;
280 pane.agents = new Map();
281 pane.sessionFamily = null;
282 // The pane follows the new conversation: its id is read again at the next drawing, and
283 // the history with it, where the conversation before is now a past session
284 pane.sessionId = null;
285 pane.rows = undefined;
286 pane.mood = null;
287 pane.lastActive = 0;
288 pane.reactUntil = 0;
289}
290
291// The pane. Where the history file is, this session's id (its own row in the file holds
292// a partial total), and the history as last read: undefined until read, null when there
293// is no file to read (no database: an install without the bash hooks). The walk in the
294// terminal: the timer that steps it; the picture follows the clock, so a redraw for
295// another reason (a new request's total) keeps the step.
296const pane: {
297 historyFile: string | null;
298 // The daily release check's flag, the running plugin's version, and the newer release
299 // the foot names (null when there is none)
300 updateFile: string | null;
301 ownVersion: string | null;
302 newer: string | null;
303 sessionId: string | null;
304 rows: SessionRow[] | null | undefined;
305 walkTimer: { cancel: () => void } | null;
306 // Bumped by every start and stop of the walk: a step begun under an older one stops,
307 // so two chains never run at once and a stopped walk never draws again.
308 walkRun: number;
309 // This session's project, its folder's name, as the hooks will record it
310 project: string;
311 // Until when the pane confirms that /carbon-card waits in the prompt (ms)
312 cardReadyUntil: number;
313 // The row of block switches is open
314 customizing: boolean;
315 // The tile whose note is open, by its (i)
316 openNote: string | null;
317 // The running session as the pane tells it: subagents started, the family of the last
318 // model that answered
319 subagents: number;
320 // Each subagent started this session, in order, and the CO2 of its requests
321 agents: Map<string, number>;
322 sessionFamily: Family | null;
323 // The molecule's mood, since when (its faces loop from there), the last turn's start
324 // or end, and until when it reacts to the last answer (ms)
325 mood: Mood | null;
326 moodSince: number;
327 // The band's site, as its last drawing named it: where the walk blits
328 bandRequestId: string | null;
329 lastActive: number;
330 reactUntil: number;
331} = {
332 historyFile: null,
333 updateFile: null,
334 ownVersion: null,
335 newer: null,
336 sessionId: null,
337 rows: undefined,
338 walkTimer: null,
339 walkRun: 0,
340 project: "",
341 cardReadyUntil: 0,
342 customizing: false,
343 openNote: null,
344 subagents: 0,
345 agents: new Map<string, number>(),
346 sessionFamily: null,
347 mood: null,
348 moodSince: 0,
349 bandRequestId: null,
350 lastActive: 0,
351 reactUntil: 0,
352};
353
354async function loadPaneHistory($: EngineInterface) {
355 pane.newer = null;
356 if (pane.updateFile !== null) {
357 try {
358 const flag = await $.fs.read(pane.updateFile);
359 if (typeof flag === "string")
360 pane.newer = newerRelease(flag, pane.ownVersion, await $.clock.now());
361 } catch {
362 pane.newer = null;
363 }
364 }
365 if (pane.historyFile === null) {
366 pane.rows = null;
367 return;
368 }
369 try {
370 const text = await $.fs.read(pane.historyFile);
371 pane.rows = typeof text === "string" ? parseHistory(text) : null;
372 } catch {
373 pane.rows = null;
374 }
375}
376
377async function isPaneOpen($: EngineInterface): Promise<boolean> {
378 try {
379 return (await $.ui.panes()).some((open) => open.id === PANE_ID);
380 } catch {
381 return false;
382 }
383}
384
385// The molecule's class (this session against the median of past sessions, null without
386// enough of them) and mood. A new mood starts its faces from the first.
387async function moleculeNow(
388 $: EngineInterface,
389 now: number,
390): Promise<{ cls: number | null; mood: Mood; isWorking: boolean }> {
391 const total = await read($, session);
392 const isWorking = await read($, working);
393 const m = colourMedian(pastSessions(pane.rows ?? [], pane.sessionId));
394 // Grey, as drawn, until this session counts something
395 const cls = !(total.grams > 0) ? null : classOf(total.grams, m);
396 if (pane.lastActive === 0) pane.lastActive = now;
397 const mood = moodOf({
398 working: isWorking,
399 cls,
400 hasAnswer: total.turns > 0,
401 idleMs: now - pane.lastActive,
402 reacting: now < pane.reactUntil,
403 });
404 if (mood !== pane.mood) {
405 pane.mood = mood;
406 pane.moodSince = now;
407 }
408 return { cls, mood, isWorking };
409}
410
411// The terminal's molecule at `now`: its hop, at the pace of its colour while a turn runs
412// and a slow breath at rest, and its face; with how long that picture holds.
413function terminalMolecule(
414 now: number,
415 mood: Mood,
416 cls: number | null,
417 isWorking: boolean,
418): { frame: string[]; holdMs: number } {
419 const pace = isWorking ? stepMs(cls ?? NEUTRAL_CLASS) : IDLE_STEP_MS;
420 const hop = isStill(mood) || Math.floor(now / pace) % 2 === 0 ? 0 : 1;
421 const { face, holdMs } = faceAt(mood, now - pane.moodSince);
422 return {
423 frame: miniFaceFrame(hop, face),
424 holdMs: Math.min(pace - (now % pace), holdMs),
425 };
426}
427
428// The terminal's whole drawing: this session beside its small molecule, a few rows. In
429// the band above the prompt, or in a pane the desktop app opened on a session the
430// terminal shows too.
431async function terminalSession(
432 $: EngineInterface,
433 e: RenderInputOf<"AbovePrompt", "terminal"> | RenderInputOf<"Pane", "terminal">,
434 lang: Lang,
435) {
436 if (pane.rows === undefined) await loadPaneHistory($);
437 if (pane.sessionId === null) pane.sessionId = await $.session.id().catch(() => null);
438 const total = await read($, session);
439 const now = await $.clock.now();
440 const past = pastSessions(pane.rows ?? [], pane.sessionId);
441 const sm = sessionMedian(past);
442 const cm = colourMedian(past);
443 const { cls, mood, isWorking } = await moleculeNow($, now);
444 const color = colorOf(cls);
445 // A dot per subagent in its molecule's colour, eight then "+N"
446 const agents = [...pane.agents.values()];
447 const dots = agents
448 .slice(0, MAX_AGENT_MOLECULES)
449 .map((grams) => (!(grams > 0) ? NEUTRAL : colorOf(classOf(grams, cm))));
450 const more = agents.length - dots.length;
451 // The context window's fill and the session's API-price value, as the status line
452 // has them
453 const contextUsed = await $.session
454 .usage()
455 .then((u) => u.context.percent ?? null)
456 .catch(() => null);
457 const meta = [
458 ...(contextUsed !== null ? [contextText(contextUsed, lang)] : []),
459 ...(total.usd !== undefined && total.usd > 0 ? [costText(total.usd, lang)] : []),
460 ].join(" · ");
461 const ui = $.ui.resolve(e);
462 const { Box, Text } = ui;
463 return (
464 <Box flexDirection="row">
465 <ui.Raster
466 key="mascot"
467 columns={CELL_COLUMNS}
468 rows={CELL_ROWS}
469 cells={mascotCells(terminalMolecule(now, mood, cls, isWorking).frame, color)}
470 />
471 <Box flexDirection="column" marginLeft={2} flexShrink={1}>
472 <Text bold>
473 {total.grams > 0 ? currentLine(total.grams, sm, lang) : waitingText(lang)}
474 </Text>
475 {total.turns > 0 && (
476 <Text dimColor>
477 {sessionStatsText(total.turns, pane.subagents, pane.sessionFamily, lang)}
478 </Text>
479 )}
480 {dots.length > 0 && (
481 <Box flexDirection="row" columnGap={1}>
482 {dots.map((dot, i) => (
483 <Text key={`agent-${i}`} color={dot}>
484 ●
485 </Text>
486 ))}
487 {more > 0 && <Text dimColor>{moreAgentsText(more)}</Text>}
488 </Box>
489 )}
490 {meta !== "" && <Text dimColor>{meta}</Text>}
491 {/* No history file, or a reference until there is a median of one's own */}
492 {pane.rows === null ? (
493 <Text dimColor>{noHistoryText(lang)}</Text>
494 ) : (
495 sm === null && <Text dimColor>{colorNote(past.length, lang)}</Text>
496 )}
497 </Box>
498 </Box>
499 );
500}
501
502// The kept choice over the defaults: a block added since it was kept takes its default.
503// Null when nothing usable was kept.
504function keptBlocksOf(value: unknown): PaneBlocks | null {
505 if (typeof value !== "object" || value === null) return null;
506 const v = value as Partial<Record<string, unknown>>;
507 const out: PaneBlocks = { ...DEFAULT_BLOCKS };
508 let isAny = false;
509 for (const block of BLOCKS) {
510 const kept = v[block];
511 if (typeof kept === "boolean") {
512 out[block] = kept;
513 isAny = true;
514 }
515 }
516 return isAny ? out : null;
517}
518
519function stopWalk() {
520 pane.walkTimer?.cancel();
521 pane.walkTimer = null;
522 pane.walkRun += 1;
523}
524
525// One step of the terminal's molecule while the pane is open: a blit of the next frame,
526// no redraw, then the next step, at the pace of the current colour while a turn runs and
527// a slow breath at rest. A blit the surface refuses (the pane in a hidden tab) is left be.
528// Each await may see the walk stopped or restarted: the step then ends without drawing.
529async function walkStep($: EngineInterface, run: number) {
530 const isCurrent = () => run === pane.walkRun;
531 try {
532 const surfaces = await $.session.surfaces();
533 const isBand = await read($, band);
534 const isPane = await isPaneOpen($);
535 const goOn = surfaces.includes("terminal") && (isBand || isPane);
536 const isWorking = await read($, working);
537 if (!isCurrent()) return;
538 if (!goOn) {
539 stopWalk();
540 return;
541 }
542 const now = await $.clock.now();
543 const { cls, mood } = await moleculeNow($, now);
544 if (!isCurrent()) return;
545 const shown = terminalMolecule(now, mood, cls, isWorking);
546 const cells = mascotCells(shown.frame, colorOf(cls));
547 const sites = [
548 ...(isPane ? [PANE_ID] : []),
549 ...(isBand && pane.bandRequestId !== null ? [pane.bandRequestId] : []),
550 ];
551 for (const requestId of sites)
552 await $.ui
553 .blit({ requestId, key: "mascot", cells, columns: CELL_COLUMNS, rows: CELL_ROWS })
554 .catch(() => undefined);
555 if (!isCurrent()) return;
556 pane.walkTimer = $.clock.after(Math.max(40, shown.holdMs), () => {
557 void walkStep($, run);
558 });
559 } catch {
560 if (isCurrent()) stopWalk();
561 }
562}
563
564function ensureWalk($: EngineInterface) {
565 if (pane.walkTimer !== null) return;
566 pane.walkRun += 1;
567 const run = pane.walkRun;
568 pane.walkTimer = $.clock.after(IDLE_STEP_MS, () => {
569 void walkStep($, run);
570 });
571}
572
573export const register: Register = (on, options) => {
574 const showPerTurn = options.showPerTurn !== false;
575 // Off where a status line already shows the total (ccstatusline with the
576 // claude-carbon segment): read like the claude-carbon status line, so the
577 // desktop app, which draws no status line, keeps it.
578 const showSessionTotal = options.showSessionTotal !== false;
579 let factors: Factors = DEFAULT_FACTORS;
580 // The car equivalence of the pane, from the same file and set as the reports
581 let factorsJson: unknown = null;
582 let car: CarEquivalence | null = null;
583 // Null when data/prices.json cannot be read: the entry then shows the CO2 alone.
584 let prices: Prices | null = null;
585 let lang: Lang = "fr";
586 let hasCarbonStatusLine = false;
587 let current: OpenTurn | null = null;
588 // Background subagents: their requests count in the session total only, since they
589 // can go on after the answer that started them has ended. Kept across /clear: an
590 // agent started before it may still be running.
591 const background = new Set<string>();
592 // The turn each foreground subagent was started in. Its requests count in that
593 // answer only, even when it outlives it.
594 const owner = new Map<string, string>();
595 // The history as this session last read or wrote it: an answer's figure is ready
596 // before the engine draws its line, with no store read in between.
597 let known: History | null = null;
598 // When each duration line was first drawn, by requestId: only a line first drawn
599 // as a turn ends takes that turn's figure, never one drawn again later. Kept here,
600 // since a drawing never writes $.state.
601 const firstDrawn = new Map<string, number>();
602 const turnOf = new Map<string, string>();
603 const taken = new Set<string>();
604
605 on("session.start", async ($, e, next) => {
606 try {
607 const text = await $.fs.read(`${$.plugin.root}/data/factors.json`);
608 factorsJson = typeof text === "string" ? JSON.parse(text) : null;
609 factors =
610 factorsJson !== null ? parseFactors(factorsJson) : DEFAULT_FACTORS;
611 } catch {
612 factors = DEFAULT_FACTORS;
613 }
614 try {
615 const text = await $.fs.read(`${$.plugin.root}/data/prices.json`);
616 prices = typeof text === "string" ? parsePrices(JSON.parse(text)) : null;
617 } catch {
618 prices = null;
619 }
620 try {
621 const text = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`);
622 const version: unknown =
623 typeof text === "string" ? JSON.parse(text)?.version : undefined;
624 pane.ownVersion = typeof version === "string" ? version : null;
625 } catch {
626 pane.ownVersion = null;
627 }
628
629 let settings: Record<string, unknown> = {};
630 try {
631 settings = await $.settings.read();
632 } catch {
633 settings = {};
634 }
635 const statusLine = settings.statusLine as { command?: unknown } | undefined;
636 hasCarbonStatusLine = isCarbonStatusLine(statusLine?.command);
637
638 // Same variables, order and sentinels as the reports. The desktop app and a
639 // Windows session usually set none: then the language setting, then French.
640 const envValues = [
641 await $.env.get("CLAUDE_CARBON_LOCALE"),
642 await $.env.get("LC_ALL"),
643 await $.env.get("LC_MESSAGES"),
644 await $.env.get("LANG"),
645 ];
646 lang = detectLang(
647 envValues,
648 hasLangVariable(envValues)
649 ? undefined
650 : languageSetting(settings.language),
651 );
652 car = carEquivalence(factorsJson, equivalenceSet(envValues, lang));
653
654 // Once per machine. A session nothing draws yet is welcomed at its first
655 // answer instead.
656 const history = await loadHistory($);
657 known = history;
658 if (!history.welcomed && e.isInteractive) {
659 const surfaces = await $.session.surfaces();
660 if (surfaces.length > 0) {
661 $.ui.toast(welcomeText(lang), { timeoutMs: 10000 });
662 known = { ...history, welcomed: true };
663 await $.store.set(HISTORY_KEY, known);
664 }
665 }
666
667 // The pane: where its history is, this session's id, the last range and step.
668 try {
669 pane.historyFile = historyPath({
670 db: await $.env.get("CLAUDE_CARBON_DB"),
671 configDir: await $.env.get("CLAUDE_CONFIG_DIR"),
672 home: await $.env.get("HOME"),
673 userProfile: await $.env.get("USERPROFILE"),
674 });
675 // The same opt-out as the status line's hint
676 const noNotice = await $.env.get("CLAUDE_CARBON_NO_UPDATE_NOTIFIER");
677 pane.updateFile =
678 noNotice !== undefined && noNotice !== ""
679 ? null
680 : updateFlagPath({
681 configDir: await $.env.get("CLAUDE_CONFIG_DIR"),
682 home: await $.env.get("HOME"),
683 userProfile: await $.env.get("USERPROFILE"),
684 });
685 pane.sessionId = await $.session.id().catch(() => null);
686 pane.project = folderName(e.cwd ?? "");
687 pane.subagents = 0;
688 pane.agents = new Map();
689 pane.sessionFamily = null;
690 pane.rows = undefined;
691 pane.mood = null;
692 pane.lastActive = 0;
693 pane.reactUntil = 0;
694 stopWalk();
695 pane.bandRequestId = null;
696 await $.state.set({ plugin: "claude-carbon", key: "working" }, false);
697 await $.state.set({ plugin: "claude-carbon", key: "band" }, false);
698 const kept = await $.store.get(VIEW_KEY);
699 if (isView(kept)) {
700 await $.state.set({ plugin: "claude-carbon", key: "view" }, kept);
701 }
702 const keptBlocks = keptBlocksOf(await $.store.get(BLOCKS_KEY));
703 if (keptBlocks !== null) {
704 await $.state.set({ plugin: "claude-carbon", key: "blocks" }, keptBlocks);
705 }
706 } catch {
707 // The pane opens on what could be read.
708 }
709
710 // Last: a taken name throws, and nothing above depends on it. Someone welcomed
711 // before the pane existed then hears of /carbon-panel once, if it is ours; a welcome
712 // shown in this very session waits for the next one: one toast at a time.
713 try {
714 await $.command.register({
715 name: COMMAND,
716 description: commandDescription(lang),
717 immediate: true,
718 });
719 if (history.welcomed && e.isInteractive) {
720 const hinted = (await $.store.get(HINT_KEY)) === true;
721 const surfaces = await $.session.surfaces();
722 if (!hinted && surfaces.length > 0) {
723 $.ui.toast(paneHint(lang), { timeoutMs: 10000 });
724 await $.store.set(HINT_KEY, true);
725 }
726 }
727 } catch {
728 // /carbon-panel is someone else's here: the rest of the mod works without it.
729 }
730
731 return next(e);
732 });
733
734 on("agent.spawn", async ($, e, next) => {
735 const started = await next(e);
736 const id = started.agentId;
737 if (id !== undefined) {
738 pane.subagents += 1;
739 pane.agents.set(id, 0);
740 const parent = e.parentAgentId;
741 // A subagent of a background subagent is in the background too, and one of a
742 // foreground subagent belongs to its parent's answer.
743 if (e.background || (parent !== undefined && background.has(parent))) {
744 background.add(id);
745 } else {
746 const turnId =
747 (parent !== undefined ? owner.get(parent) : undefined) ??
748 current?.turnId;
749 if (turnId !== undefined) owner.set(id, turnId);
750 if (current !== null && turnId === current.turnId) {
751 current.spawned.add(id);
752 }
753 }
754 }
755 return started;
756 }).catch(($, e, next) => next(e));
757
758 // A foreground subagent sent to the background (ctrl+b) ends its Agent call with
759 // this status: from then on it counts like one started there.
760 // @ts-ignore TS2589 under TypeScript 5.6, which gives up on this matcher's types
761 on("tool.call", { tool: "Agent" }, async ($, e, next) => {
762 const called = await next(e);
763 const record = called.result as
764 { status?: unknown; agentId?: unknown } | undefined;
765 if (
766 record?.status === "async_launched" &&
767 typeof record.agentId === "string"
768 ) {
769 background.add(record.agentId);
770 current?.spawned.delete(record.agentId);
771 }
772 return called;
773 }).catch(($, e, next) => next(e));
774
775 on("turn.start", async ($, e, next) => {
776 // The main loop alone raises turn.start; a subagent's run does not.
777 current = openTurn(e.turnId);
778 try {
779 pane.lastActive = await $.clock.now();
780 await $.state.set({ plugin: "claude-carbon", key: "working" }, true);
781 if ((await read($, band)) || (await isPaneOpen($))) ensureWalk($);
782 } catch {
783 // The molecule stands still; the turn goes on.
784 }
785 return next(e);
786 });
787
788 on("turn.step", async function* ($, e, next) {
789 const result = yield* next(e);
790 try {
791 const usage = result.usage;
792 if (usage !== null) {
793 const model = usage.model !== "" ? usage.model : e.model;
794 if (!isExcluded(model, factors.excludeModels)) {
795 const grams = co2Grams(usage, model, factors);
796 pane.sessionFamily = family(model);
797 if (e.agentId !== undefined && pane.agents.has(e.agentId))
798 pane.agents.set(e.agentId, (pane.agents.get(e.agentId) ?? 0) + grams);
799 const usd = prices !== null ? costUsd(usage, model, prices) : null;
800 await update($, session, ({ usd: spent, tokens: seen, ...total }) => ({
801 ...total,
802 grams: total.grams + grams,
803 // Counted from the first request of the session on; a total kept without
804 // them stays without them until it starts over
805 ...(seen !== undefined
806 ? {
807 tokens: {
808 input: seen.input + usage.input_tokens,
809 cacheWrite: seen.cacheWrite + usage.cache_creation_input_tokens,
810 cacheRead: seen.cacheRead + usage.cache_read_input_tokens,
811 output: seen.output + usage.output_tokens,
812 },
813 }
814 : {}),
815 // A request with no price, or a total kept from code that counted no cost,
816 // leaves the cost out until the total starts over.
817 ...(usd !== null && spent !== undefined ? { usd: spent + usd } : {}),
818 }));
819 const agent = e.agentId;
820 // Outside an open answer, from a background subagent or from a subagent of
821 // an earlier answer: session total only. An agent no spawn named (the
822 // engine's own work, a compaction) counts in the open answer.
823 if (
824 current !== null &&
825 (agent === undefined ||
826 (!background.has(agent) &&
827 (owner.get(agent) ?? current.turnId) === current.turnId))
828 ) {
829 current.grams += grams;
830 // Work the engine runs on its own (a compaction) is in the answer's
831 // total but is not named as a subagent.
832 if (agent !== undefined && current.spawned.has(agent)) {
833 current.subagentGrams += grams;
834 current.stepped.add(agent);
835 }
836 }
837 }
838 }
839 } catch {
840 // A failed measure never touches the response.
841 }
842 return result;
843 });
844
845 on("turn.complete", async ($, e, next) => {
846 if (e.agentId !== undefined) return next(e);
847 // Before anything that may return early: a module reloaded during the turn has no
848 // open answer, and the molecule must still slow down (its next step reads `working`).
849 // The Stop hook writes the history again after this answer, other sessions running at
850 // the same time included: the pane's next drawing reads it again (and finds it, when
851 // an update installed in this very session had none yet).
852 pane.rows = undefined;
853 try {
854 await $.state.set({ plugin: "claude-carbon", key: "working" }, false);
855 } catch {
856 // The next session start sets it again.
857 }
858 // The molecule reacts to the answer, then falls asleep if nothing follows. On the
859 // desktop nothing else redraws it at those two moments.
860 try {
861 const now = await $.clock.now();
862 pane.lastActive = now;
863 pane.reactUntil = now + REACT_MS;
864 if (await isPaneOpen($)) {
865 $.clock.after(REACT_MS, () => $.ui.invalidate("ui.render"));
866 $.clock.after(SLEEP_AFTER_MS, () => $.ui.invalidate("ui.render"));
867 }
868 } catch {
869 // It keeps its face until the next drawing.
870 }
871 // Only the turn that opened the answer closes it.
872 const done = current;
873 if (done === null || e.turnId !== done.turnId) return next(e);
874 current = null;
875 const isCounted = done.grams > 0;
876 let co2: AnswerCo2Entry["co2"] = null;
877 try {
878 // Written before the engine draws the turn's duration line, which takes the
879 // entry written within a second of its first draw.
880 if (known === null) known = await loadHistory($);
881 co2 = isCounted
882 ? buildAnswerCo2(
883 {
884 grams: done.grams,
885 subagentGrams: done.subagentGrams,
886 subagents: done.stepped.size,
887 },
888 known.values,
889 lang,
890 )
891 : null;
892 const at = await $.clock.now();
893 await update($, answers, (all) =>
894 [
895 ...all,
896 { turnId: done.turnId, durationMs: e.durationMs, at, co2 },
897 ].slice(-KEEP_ANSWERS),
898 );
899 } catch {
900 // The line stays as the engine draws it.
901 }
902 const result = await next(e);
903 try {
904 const history = await loadHistory($);
905 const surfaces = await $.session.surfaces();
906 const remoteOnly = isRemoteOnly(surfaces);
907 const isWelcome = !history.welcomed && surfaces.length > 0;
908 if (isWelcome) {
909 $.ui.toast(welcomeText(lang), { timeoutMs: 10000 });
910 }
911 known = history;
912 if (isCounted || isWelcome) {
913 known = {
914 ...history,
915 ...(isCounted
916 ? { values: pushHistory(history.values, done.grams) }
917 : {}),
918 welcomed: history.welcomed || isWelcome,
919 };
920 await $.store.set(HISTORY_KEY, known);
921 }
922 await update($, session, (total) => ({
923 ...total,
924 turns: total.turns + 1,
925 }));
926 const { value: total } = await $.state.get({
927 plugin: "claude-carbon",
928 key: "session",
929 });
930 // A status line only shows in a terminal.
931 if (
932 ((showSessionTotal && !hasCarbonStatusLine) || remoteOnly) &&
933 total !== undefined &&
934 total.grams > 0
935 ) {
936 $.ui.status(sessionLine(total.grams, lang, total.usd ?? null));
937 }
938 // The figure becomes a line under the answer, for unusual answers only.
939 if (co2 !== null && co2.isUnusual && showPerTurn && remoteOnly) {
940 return { ...result, text: answerLine(co2) };
941 }
942 } catch {
943 // A failure here leaves the answer as it was.
944 }
945 return result;
946 });
947
948 on("session.end", async ($, e, next) => {
949 // /clear and /resume (and /branch, reported as resume) go on in another conversation.
950 if (e.reason === "clear" || e.reason === "resume") {
951 current = null;
952 await resetSessionState($);
953 }
954 return next(e);
955 });
956
957 // /carbon-panel opens the pane, or closes it when it is open. Opened without the keyboard:
958 // the pane has nothing to type into, and Escape must still interrupt a turn. The
959 // command is also a skill of the plugin (skills/carbon-panel), so the desktop app lists
960 // it: its plugin spelling is caught here too, and its text never reaches the model.
961 on("command.run", { command: [COMMAND, `claude-carbon:${COMMAND}`] }, async ($, e) => {
962 try {
963 // Typed in a terminal, the molecule shows in the band above the prompt, on and off:
964 // a pane would dock as a whole column in fullscreen rendering, for four lines.
965 // Sent from the desktop app or a phone, it opens the side pane there.
966 const surfaces = await $.session.surfaces();
967 if (panelSite(surfaces, e.origin?.kind ?? "") === "band") {
968 const isOn = !(await read($, band));
969 if (isOn) {
970 pane.rows = undefined;
971 await loadPaneHistory($);
972 } else {
973 stopWalk();
974 }
975 await $.state.set({ plugin: "claude-carbon", key: "band" }, isOn);
976 if (isOn) ensureWalk($);
977 return {};
978 }
979 if (await isPaneOpen($)) {
980 stopWalk();
981 await $.ui.close({ id: PANE_ID });
982 return {};
983 }
984 pane.rows = undefined;
985 await loadPaneHistory($);
986 // The desktop app's side pane
987 const opened = await $.ui.open({
988 id: PANE_ID,
989 title: paneTitle(lang),
990 closeOnEscape: true,
991 });
992 if (!opened.isPlaced) {
993 $.ui.toast(paneUnplaced(lang), { timeoutMs: 8000 });
994 } else {
995 ensureWalk($);
996 }
997 } catch {
998 // Nothing opened: the command ends quietly.
999 }
1000 return {};
1001 }).catch(() => ({}));
1002
1003 on("ui.close", ($, e, next) => {
1004 if (e.id === PANE_ID) stopWalk();
1005 return next(e);
1006 }).catch(($, e, next) => next(e));
1007
1008 // The band above a terminal's prompt, while /carbon-panel has it on: the session beside
1009 // its molecule, over whatever else the band holds. A survey has the band to itself.
1010 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1011 try {
1012 if (e.surface !== "terminal" || e.props.hasSurvey || !(await read($, band)))
1013 return next(e);
1014 pane.bandRequestId = e.requestId;
1015 const ours = await terminalSession($, e, lang);
1016 const below = await next(e);
1017 if (below === null || below === undefined) return ours;
1018 const { Box } = $.ui.resolve(e);
1019 return (
1020 <Box flexDirection="column">
1021 {ours}
1022 {below}
1023 </Box>
1024 );
1025 } catch {
1026 return next(e);
1027 }
1028 });
1029
1030 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e, next) => {
1031 try {
1032 if (e.surface === "terminal") return await terminalSession($, e, lang);
1033 if (pane.rows === undefined) await loadPaneHistory($);
1034 if (pane.sessionId === null)
1035 pane.sessionId = await $.session.id().catch(() => null);
1036 const total = await read($, session);
1037 const isWorking = await read($, working);
1038 const view = await read($, paneView);
1039 const now = await $.clock.now();
1040 const past = pastSessions(pane.rows ?? [], pane.sessionId);
1041 const sm = sessionMedian(past);
1042 const cm = colourMedian(past);
1043 // Grey until this session has counted something
1044 const cls = !(total.grams > 0) ? null : classOf(total.grams, cm);
1045 const color = colorOf(cls);
1046 const { mood } = await moleculeNow($, now);
1047 // The running session joins the stripes with its live total, as the newest, at
1048 // the start its own row in the file gives.
1049 const own = (pane.rows ?? []).find((r) => r.id === pane.sessionId);
1050 const isLive = total.grams > 0;
1051 const rows = isLive
1052 ? [
1053 ...past,
1054 {
1055 id: pane.sessionId ?? "current",
1056 at: own?.at ?? now,
1057 grams: total.grams,
1058 version: CURRENT_VERSION,
1059 project: own?.project ?? pane.project,
1060 usd: total.usd ?? null,
1061 model: own?.model ?? "",
1062 // The running session's tokens counted live, or as the file last saw them
1063 tokens: total.tokens ?? own?.tokens ?? null,
1064 },
1065 ]
1066 : past;
1067 // The window the view covers, and one stripe per day, week or month across all of
1068 // it: the stripes always match the period picked.
1069 const w = windowOf(view, rows, now);
1070 const list = pane.rows === null ? [] : bars(rows, w);
1071 // The stripes measure the periods against each other, the one still filling (today,
1072 // this week, this month) left out of the median.
1073 const bm = barMedian(list.slice(0, -1));
1074 // A window longer than the room (years by month) keeps its most recent periods
1075 const shown = list.slice(-DESKTOP_MAX_STRIPES);
1076 const stripes: Stripe[] = shown.map((b) => ({
1077 color: b.grams > 0 ? (bm === null ? NEUTRAL : colorOf(classOf(b.grams, bm))) : null,
1078 title: stripeTitle(b.start, w.step, b.grams, b.sessions, lang),
1079 start: b.start,
1080 }));
1081 const blocks = await read($, paneBlocks);
1082 const setView = (chosen: PaneView) => {
1083 void (async () => {
1084 await $.state.set({ plugin: "claude-carbon", key: "view" }, chosen);
1085 await $.store.set(VIEW_KEY, chosen);
1086 })().catch(() => undefined);
1087 };
1088
1089 const ui = $.ui.resolve(e);
1090 const { Box, Text, Button } = ui;
1091 // The molecule, and under it the colour scale with this session's place on it
1092 const mascot = (
1093 <Box flexDirection="column" alignItems="center">
1094 <ui.Svg
1095 source={mascotSvg({
1096 color,
1097 walking: isWorking,
1098 step: stepMs(cls ?? NEUTRAL_CLASS),
1099 title: mascotAlt(lang),
1100 mood,
1101 })}
1102 alt={mascotAlt(lang)}
1103 width={112}
1104 height={90}
1105 isInteractive
1106 />
1107 <ui.Svg
1108 source={scaleSvg(cls)}
1109 alt={scaleAlt(cls, lang)}
1110 width={SCALE_WIDTH}
1111 height={SCALE_HEIGHT}
1112 />
1113 </Box>
1114 );
1115 // The chosen button in the app's main style, the others dim
1116 const choice = (isChosen: boolean) =>
1117 isChosen ? { variant: "primary" as const } : { dimColor: true };
1118 const sessionText = total.grams > 0 ? currentLine(total.grams, sm, lang) : waitingText(lang);
1119 const hasStripes = pane.rows !== null && stripes.length > 0;
1120 // Periods with sessions in them: below three, the stripes say they are coming
1121 const filledStripes = shown.filter((b) => b.sessions > 0).length;
1122 const withYear = w.end - w.start > 300 * 86_400_000;
1123 const firstShown = shown[0];
1124 const dates =
1125 firstShown === undefined
1126 ? ""
1127 : `${dateLabel(Math.max(w.start, firstShown.start), w.step, lang, withYear)} → ${dateLabel(w.end, w.step, lang, withYear)}`;
1128
1129 const rangeButtons = RANGES.map((range) => (
1130 <Button
1131 key={`range-${range}`}
1132 label={rangeLabel(range, lang)}
1133 {...choice(view.range === range)}
1134 onPress={() => {
1135 if (range !== "custom") return setView({ range });
1136 // A custom range opens on the last 30 days, then the fields move it
1137 setView({
1138 range,
1139 from: view.from ?? dayKey(now - 29 * 86_400_000),
1140 to: view.to ?? dayKey(now),
1141 });
1142 }}
1143 />
1144 ));
1145 // The custom range: two dates typed, read at Enter. The mods API has no calendar:
1146 // a field takes the day first ("01/05/2026") or as 2026-05-01. A start after the
1147 // end swaps them.
1148 const setDay = (field: "from" | "to", typed: string) => {
1149 const key = parseDay(typed);
1150 if (key === null) {
1151 $.ui.toast(dateInvalid(lang), { timeoutMs: 6000 });
1152 return;
1153 }
1154 const from = field === "from" ? key : (view.from ?? key);
1155 const to = field === "to" ? key : (view.to ?? key);
1156 setView({ range: "custom", from: from <= to ? from : to, to: from <= to ? to : from });
1157 };
1158 // A phone has no text field (Remote Control): the two dates show as text there.
1159 const customRange =
1160 view.range === "custom" &&
1161 ("Input" in ui ? (
1162 <Box flexDirection="row" columnGap={1} flexWrap="wrap" alignItems="center">
1163 <ui.Input
1164 key="from"
1165 label={fromLabel(lang)}
1166 placeholder={datePlaceholder(lang)}
1167 value={view.from !== undefined ? dayInputValue(view.from) : ""}
1168 submitLabel="ok"
1169 onSubmit={(value: string) => setDay("from", value)}
1170 />
1171 <ui.Input
1172 key="to"
1173 label={toLabel(lang)}
1174 placeholder={datePlaceholder(lang)}
1175 value={view.to !== undefined ? dayInputValue(view.to) : ""}
1176 submitLabel="ok"
1177 onSubmit={(value: string) => setDay("to", value)}
1178 />
1179 </Box>
1180 ) : (
1181 <Text dimColor>
1182 {`${dayInputValue(view.from ?? dayKey(now))} → ${dayInputValue(view.to ?? dayKey(now))}`}
1183 </Text>
1184 ));
1185
1186 // Puts /carbon-card in an empty prompt, never sends it: the card is a skill, a
1187 // model turn the person starts themselves. A draft in progress is left alone.
1188 const prepareCard = () => {
1189 void (async () => {
1190 const box = await $.prompt.read();
1191 if (box.text.trim() !== "") {
1192 $.ui.toast(cardDraftBusy(lang), { timeoutMs: 6000 });
1193 return;
1194 }
1195 const filled = await $.prompt.fill({ text: "/carbon-card" });
1196 if (!filled.isFilled) {
1197 $.ui.toast(cardFillFailed(lang), { timeoutMs: 6000 });
1198 return;
1199 }
1200 // Said twice, since a prompt filled from a side pane is easy to miss: a toast,hooks/mod/lib/co2.ts 109 lines1// CO2 of one model request, with the same formula, factors and model resolution as the
2// bash plugin (METHODOLOGY.md, scripts/portable-lib.sh: cc_family, cc_params,
3// cc_is_excluded_model). Checked against tests/methodology-vectors*.json by
4// tests/run-mod-tests.mjs. Pure: no Claude Code API, so Node runs it directly.
5
6export type Family = "fable" | "opus" | "sonnet" | "haiku";
7
8export type FamilyFactors = { input: number; output: number };
9
10export type Factors = {
11 models: Record<Family, FamilyFactors>;
12 cacheReadFactor: number;
13 excludeModels: string[];
14};
15
16export type TokenUsage = {
17 input_tokens: number;
18 cache_creation_input_tokens: number;
19 cache_read_input_tokens: number;
20 output_tokens: number;
21};
22
23// The jq fallbacks in CC_JQ_MODEL_DEFS, used when factors.json lacks a value.
24export const DEFAULT_FACTORS: Factors = {
25 models: {
26 fable: { input: 156, output: 3304 },
27 opus: { input: 78, output: 1652 },
28 sonnet: { input: 39, output: 826 },
29 haiku: { input: 10, output: 207 },
30 },
31 cacheReadFactor: 0.08,
32 excludeModels: [],
33};
34
35const FAMILIES: Family[] = ["fable", "opus", "sonnet", "haiku"];
36
37export function family(model: string): Family {
38 const m = model.toLowerCase();
39 if (m.includes("haiku")) return "haiku";
40 if (m.includes("opus")) return "opus";
41 if (/fable|mythos/.test(m)) return "fable";
42 return "sonnet";
43}
44
45function numberOr(value: unknown, fallback: number): number {
46 return typeof value === "number" && Number.isFinite(value) ? value : fallback;
47}
48
49// Reads data/factors.json as parsed JSON, falling back per value like `//` in jq.
50export function parseFactors(json: unknown): Factors {
51 const root = (
52 typeof json === "object" && json !== null ? json : {}
53 ) as Record<string, unknown>;
54 const models = (
55 typeof root.models === "object" && root.models !== null ? root.models : {}
56 ) as Record<string, Record<string, unknown> | undefined>;
57 const resolved = {} as Record<Family, FamilyFactors>;
58 for (const name of FAMILIES) {
59 const entry = models[name] ?? {};
60 resolved[name] = {
61 input: numberOr(entry.input, DEFAULT_FACTORS.models[name].input),
62 output: numberOr(entry.output, DEFAULT_FACTORS.models[name].output),
63 };
64 }
65 const exclude = Array.isArray(root.exclude_models)
66 ? root.exclude_models.filter(
67 (p): p is string => typeof p === "string" && p !== "",
68 )
69 : [];
70 return {
71 models: resolved,
72 cacheReadFactor: numberOr(
73 root.cache_read_factor,
74 DEFAULT_FACTORS.cacheReadFactor,
75 ),
76 excludeModels: exclude,
77 };
78}
79
80// Not an Anthropic Claude model (a local model behind ANTHROPIC_BASE_URL, the
81// "<synthetic>" marker), or matching one of the user's exclude_models patterns.
82export function isExcluded(model: string, patterns: string[]): boolean {
83 if (!/claude/i.test(model)) return true;
84 return patterns.some((pattern) => {
85 try {
86 return new RegExp(pattern, "i").test(model);
87 } catch {
88 return false;
89 }
90 });
91}
92
93// input_tokens is the uncached input; cache writes are a full prefill at the input
94// factor; cache reads at cache_read_factor of it.
95export function co2Grams(
96 usage: TokenUsage,
97 model: string,
98 factors: Factors,
99): number {
100 const f = factors.models[family(model)];
101 const input = usage.input_tokens + usage.cache_creation_input_tokens;
102 return (
103 (input * f.input +
104 usage.cache_read_input_tokens * (f.input * factors.cacheReadFactor) +
105 usage.output_tokens * f.output) /
106 1e6
107 );
108}
109hooks/mod/lib/cost.ts 174 lines1// API-price cost of one model request, with the same formula, prices and model resolution
2// as the bash plugin's cost_usd (METHODOLOGY.md, scripts/portable-lib.sh: cc_override,
3// cc_params, CC_AWK_PRICE). Checked against the expected_cost_usd of
4// tests/methodology-vectors*.json by tests/run-mod-tests.mjs. Pure: no Claude Code API,
5// so Node runs it directly.
6
7import { family } from "./co2.ts";
8import type { Family, TokenUsage } from "./co2.ts";
9
10export type FamilyPrices = { input: number; output: number };
11
12// What a model_overrides entry may set; anything left out falls back to the family
13// price and the global multipliers.
14export type PriceOverride = {
15 input?: number;
16 output?: number;
17 cacheWriteMultiplier?: number;
18 cacheWriteMultiplier1h?: number;
19 cacheReadMultiplier?: number;
20};
21
22export type Prices = {
23 models: Record<Family, FamilyPrices>;
24 overrides: Array<[string, PriceOverride]>;
25 cacheWriteMultiplier: number;
26 cacheWriteMultiplier1h: number;
27 cacheReadMultiplier: number;
28};
29
30// One model's prices once resolved, in USD per million tokens.
31export type ModelPrices = {
32 input: number;
33 output: number;
34 cacheWriteMultiplier: number;
35 cacheWriteMultiplier1h: number;
36 cacheReadMultiplier: number;
37};
38
39// The jq fallbacks in CC_JQ_MODEL_DEFS, used when prices.json lacks a value.
40export const DEFAULT_PRICES: Prices = {
41 models: {
42 fable: { input: 10, output: 50 },
43 opus: { input: 4, output: 20 },
44 sonnet: { input: 2, output: 10 },
45 haiku: { input: 0.1, output: 0.5 },
46 },
47 overrides: [],
48 cacheWriteMultiplier: 1.25,
49 cacheWriteMultiplier1h: 2.0,
50 cacheReadMultiplier: 0.1,
51};
52
53const FAMILIES: Family[] = ["fable", "opus", "sonnet", "haiku"];
54
55function isObject(value: unknown): value is Record<string, unknown> {
56 return typeof value === "object" && value !== null && !Array.isArray(value);
57}
58
59function numberOr(value: unknown, fallback: number): number {
60 return typeof value === "number" && Number.isFinite(value) ? value : fallback;
61}
62
63function numberOrUndefined(value: unknown): number | undefined {
64 return typeof value === "number" && Number.isFinite(value)
65 ? value
66 : undefined;
67}
68
69// Reads data/prices.json as parsed JSON, falling back per value like `//` in jq. Null
70// when it is not a JSON object: there is no price to show then.
71export function parsePrices(json: unknown): Prices | null {
72 if (!isObject(json)) return null;
73 const models = isObject(json.models) ? json.models : {};
74 const resolved = {} as Record<Family, FamilyPrices>;
75 for (const name of FAMILIES) {
76 const entry = isObject(models[name]) ? models[name] : {};
77 resolved[name] = {
78 input: numberOr(entry.input, DEFAULT_PRICES.models[name].input),
79 output: numberOr(entry.output, DEFAULT_PRICES.models[name].output),
80 };
81 }
82 const overrides: Array<[string, PriceOverride]> = [];
83 if (isObject(json.model_overrides)) {
84 for (const [key, value] of Object.entries(json.model_overrides)) {
85 if (key.startsWith("_") || !isObject(value)) continue;
86 overrides.push([
87 key.toLowerCase(),
88 {
89 input: numberOrUndefined(value.input),
90 output: numberOrUndefined(value.output),
91 cacheWriteMultiplier: numberOrUndefined(value.cache_write_multiplier),
92 cacheWriteMultiplier1h: numberOrUndefined(
93 value.cache_write_multiplier_1h,
94 ),
95 cacheReadMultiplier: numberOrUndefined(value.cache_read_multiplier),
96 },
97 ]);
98 }
99 }
100 return {
101 models: resolved,
102 overrides,
103 cacheWriteMultiplier: numberOr(
104 json.cache_write_multiplier,
105 DEFAULT_PRICES.cacheWriteMultiplier,
106 ),
107 cacheWriteMultiplier1h: numberOr(
108 json.cache_write_multiplier_1h,
109 DEFAULT_PRICES.cacheWriteMultiplier1h,
110 ),
111 cacheReadMultiplier: numberOr(
112 json.cache_read_multiplier,
113 DEFAULT_PRICES.cacheReadMultiplier,
114 ),
115 };
116}
117
118// A key matches the id exactly, or as a prefix followed by nothing but a dated snapshot
119// suffix "-YYYYMMDD"; a trailing context marker such as "[1m]" is ignored; the longest
120// matching key wins.
121export function priceOverride(
122 model: string,
123 overrides: Array<[string, PriceOverride]>,
124): PriceOverride {
125 const m = model.toLowerCase().replace(/\[[^\]]*\]$/, "");
126 let best: [string, PriceOverride] | undefined;
127 for (const entry of overrides) {
128 const [key] = entry;
129 const isMatch =
130 m === key ||
131 (m.startsWith(`${key}-`) && /^[0-9]{8}$/.test(m.slice(key.length + 1)));
132 if (isMatch && (best === undefined || key.length > best[0].length)) {
133 best = entry;
134 }
135 }
136 return best?.[1] ?? {};
137}
138
139export function modelPrices(model: string, prices: Prices): ModelPrices {
140 const base = prices.models[family(model)];
141 const o = priceOverride(model, prices.overrides);
142 return {
143 input: o.input ?? base.input,
144 output: o.output ?? base.output,
145 cacheWriteMultiplier: o.cacheWriteMultiplier ?? prices.cacheWriteMultiplier,
146 cacheWriteMultiplier1h:
147 o.cacheWriteMultiplier1h ?? prices.cacheWriteMultiplier1h,
148 cacheReadMultiplier: o.cacheReadMultiplier ?? prices.cacheReadMultiplier,
149 };
150}
151
152// cacheWrite1h is the part of the cache writes at the 1-hour tier, clamped to the
153// total write; the rest is priced at the 5-minute tier. turn.step does not split the
154// writes by tier: left out, they are all at the 1-hour tier, the one Claude Code writes
155// at (data/prices.json, _cache_write_multipliers).
156export function costUsd(
157 usage: TokenUsage,
158 model: string,
159 prices: Prices,
160 cacheWrite1h: number = usage.cache_creation_input_tokens,
161): number {
162 const p = modelPrices(model, prices);
163 const write = usage.cache_creation_input_tokens;
164 const write1h = Math.min(Math.max(cacheWrite1h, 0), write);
165 return (
166 (usage.input_tokens * p.input +
167 write1h * (p.input * p.cacheWriteMultiplier1h) +
168 (write - write1h) * (p.input * p.cacheWriteMultiplier) +
169 usage.cache_read_input_tokens * (p.input * p.cacheReadMultiplier) +
170 usage.output_tokens * p.output) /
171 1e6
172 );
173}
174hooks/mod/lib/detail.ts 191 lines1// The pane's "Détail" block: a period's tokens, energy and CO2 by kind of token, with two
2// stacked bars (the share of the tokens, the share of the CO2). Pure.
3//
4// The CO2 of each session is the one the hooks stored; it is split over the four kinds
5// with the weights the factors give them (input and cache writes 1, cache reads
6// cache_read_factor, output output/input of the session's family), so the parts add up
7// to what was stored. A session stored with older factors (a frozen month, or a row
8// written before cache reads were recorded) is left out: its stored CO2 no longer matches
9// its tokens under today's factors (2.3 to 6.4 times them in May and June 2026), so
10// today's weights would misplace it. A session that mixed models sits between 0.5 and
11// 2 times the CO2 of its dominant model and splits the same way, as every family weighs
12// its kinds alike: it is kept.
13// The factors start from energy per token (PUE included) and convert it to CO2 with the
14// grid intensity (0.287 kgCO2e/kWh, AWS us-east location-based, data/factors.json
15// _methodology). The stored CO2 is divided back by that intensity to show the energy.
16import { family } from "./co2.ts";
17import { HOVER_LABEL_HEIGHT, hoverLabelSvg } from "./mascot.ts";
18import type { Factors } from "./co2.ts";
19import type { SessionRow, TokenSplit, Window } from "./sessions.ts";
20
21export const KINDS = ["input", "cacheWrite", "cacheRead", "output"] as const;
22export type Kind = (typeof KINDS)[number];
23
24export const GRID_G_PER_WH = 0.287;
25// How far a stored CO2 may sit from its tokens under today's factors and still be split
26const MIN_RATIO = 0.4;
27const MAX_RATIO = 2.5;
28
29export type DetailPart = {
30 kind: Kind;
31 tokens: number;
32 grams: number;
33 wh: number;
34 tokenShare: number;
35 gramShare: number;
36};
37
38export type Detail = {
39 parts: DetailPart[];
40 tokens: number;
41 grams: number;
42 wh: number;
43 // Sessions of the period that carry their tokens, out of all of them
44 sessions: number;
45 sessionsInPeriod: number;
46};
47
48export function weightsOf(model: string, factors: Factors): TokenSplit {
49 const f = factors.models[family(model)];
50 return {
51 input: 1,
52 cacheWrite: 1,
53 cacheRead: factors.cacheReadFactor,
54 output: f.input > 0 ? f.output / f.input : 0,
55 };
56}
57
58// The period's detail, or null when none of its sessions carries its tokens yet
59export function periodDetail(
60 rows: readonly SessionRow[],
61 w: Window,
62 factors: Factors,
63): Detail | null {
64 const inPeriod = rows.filter((r) => r.at >= w.start && r.at <= w.end);
65 const tokens: TokenSplit = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
66 const grams: TokenSplit = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
67 let sessions = 0;
68 for (const r of inPeriod) {
69 if (r.tokens === null) continue;
70 const wt = weightsOf(r.model, factors);
71 const weighted = KINDS.map((k) => r.tokens![k] * wt[k]);
72 const sum = weighted.reduce((s, v) => s + v, 0);
73 // The CO2 these tokens make with today's factors, in grams
74 const expected = (sum * factors.models[family(r.model)].input) / 1e6;
75 if (!(expected > 0)) continue;
76 const ratio = r.grams / expected;
77 if (ratio < MIN_RATIO || ratio > MAX_RATIO) continue;
78 sessions += 1;
79 KINDS.forEach((k, i) => {
80 tokens[k] += r.tokens![k];
81 grams[k] += (r.grams * (weighted[i] ?? 0)) / sum;
82 });
83 }
84 if (sessions === 0) return null;
85 const totalTokens = KINDS.reduce((s, k) => s + tokens[k], 0);
86 const totalGrams = KINDS.reduce((s, k) => s + grams[k], 0);
87 return {
88 parts: KINDS.map((kind) => ({
89 kind,
90 tokens: tokens[kind],
91 grams: grams[kind],
92 wh: grams[kind] / GRID_G_PER_WH,
93 tokenShare: totalTokens > 0 ? tokens[kind] / totalTokens : 0,
94 gramShare: totalGrams > 0 ? grams[kind] / totalGrams : 0,
95 })),
96 tokens: totalTokens,
97 grams: totalGrams,
98 wh: totalGrams / GRID_G_PER_WH,
99 sessions,
100 sessionsInPeriod: inPeriod.length,
101 };
102}
103
104// One colour per kind, in the bars' order, from the author's palette: Amethyst for
105// uncached input, sand and Pierre for the cache written and re-read, Tangerine for the
106// output, which weighs far more than its share of the tokens. A light and a dark step;
107// neighbours stay apart for colour-blind readers on both surfaces, and every part is
108// also named with its figures in the text under the bars.
109export const DETAIL_COLORS = ["#1b0b45", "#c4b8aa", "#7a6e63", "#f55c0f"] as const;
110export const DETAIL_COLORS_DARK = ["#8b7fd6", "#6b6158", "#a89c8e", "#f55c0f"] as const;
111const BAR_HEIGHT = 12;
112// The bar, then a band under it where the hovered part's label shows, a pixel to spare
113const LABEL_TOP = BAR_HEIGHT + 4;
114export const DETAIL_SVG_HEIGHT = LABEL_TOP + HOVER_LABEL_HEIGHT + 1;
115const GAP = 2;
116
117function colorCss(): string {
118 const rule = (colors: readonly string[]) =>
119 colors.map((c, i) => `.k${i}{fill:${c}}`).join("");
120 return `${rule(DETAIL_COLORS)}@media (prefers-color-scheme: dark){${rule(DETAIL_COLORS_DARK)}}`;
121}
122
123// A bar split by `shares` (in the kinds' order) across `width` CSS pixels, 2 px apart.
124// As on the stripes: all the parts first, then their labels, hidden, in a layer above;
125// hovering part i lights it up and shows label i at once in a dark box under the bar,
126// readable on a light or a dark pane. A CSS :hover and the sibling selector, no script,
127// no <title> (the app's own tooltip would show a second label late). Parts under half a
128// pixel are left out; their figures stay in the text under the bars. `name` prefixes
129// the ids and the hover classes, so two bars (and the stripes) never answer each other's
130// hover should the app put the drawings in one document; the colour classes are the
131// swatches' own, with the same rules.
132export function splitBarSvg(
133 shares: readonly number[],
134 labels: readonly string[],
135 width: number,
136 name = "bar",
137): string {
138 const drawn = shares
139 .map((s, i) => ({ s, i }))
140 .filter(({ s }) => s * width >= 0.5);
141 const room = width - GAP * Math.max(0, drawn.length - 1);
142 const total = drawn.reduce((t, d) => t + d.s, 0) || 1;
143 // A part under a pixel is drawn at one; the others share what is left, so the last
144 // part still ends at the bar's right edge
145 const thin = drawn.filter(({ s }) => (room * s) / total < 1);
146 const thick = total - thin.reduce((t, d) => t + d.s, 0) || 1;
147 const left = Math.max(0, room - thin.length);
148 let x = 0;
149 const parts = drawn.map(({ s, i }) => {
150 const wd = (room * s) / total < 1 ? 1 : (left * s) / thick;
151 const part = { i, x, wd };
152 x += wd + GAP;
153 return part;
154 });
155 const rects = parts
156 .map(
157 ({ i, x: px, wd }) =>
158 `<rect id="${name}-p${i}" class="${name}-b k${i}" x="${px.toFixed(2)}" y="0" width="${wd.toFixed(2)}" height="${BAR_HEIGHT}" rx="2"/>`,
159 )
160 .join("");
161 const boxes = parts
162 .map(({ i, x: px, wd }) =>
163 hoverLabelSvg({
164 id: `${name}-t${i}`,
165 cls: `${name}-t`,
166 text: labels[i] ?? "",
167 centre: px + wd / 2,
168 y: LABEL_TOP,
169 width,
170 }),
171 )
172 .join("");
173 const show = parts.map(({ i }) => `#${name}-p${i}:hover~#${name}-t${i}`).join(",");
174 const style =
175 `<style>${colorCss()}.${name}-t{opacity:0;pointer-events:none}.${name}-b:hover{opacity:.75}` +
176 (parts.length > 0 ? `${show}{opacity:1}` : "") +
177 `</style>`;
178 return (
179 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${DETAIL_SVG_HEIGHT}" width="${width}" height="${DETAIL_SVG_HEIGHT}">` +
180 `${style}${rects}${boxes}</svg>`
181 );
182}
183
184// The colour key of a kind, a small rounded square
185export function swatchSvg(index: number): string {
186 return (
187 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10">` +
188 `<style>${colorCss()}</style><rect class="k${index}" x="0" y="0" width="10" height="10" rx="2"/></svg>`
189 );
190}
191hooks/mod/lib/format.ts 789 lines1// What an answer's CO2 line says and when it raises its voice. Pure: no Claude Code API, so
2// Node runs it directly (tests/run-mod-tests.mjs).
3
4export type Lang = "fr" | "en";
5
6export type AnswerCo2View = {
7 // "16 gCO₂", or "230 gCO₂ · 14× votre médiane" for an unusual answer
8 main: string;
9 // "dont 3 sous-agents : 22 gCO₂", only on an unusual answer the subagents weighed on
10 sub: string | null;
11 isUnusual: boolean;
12};
13
14export type TurnTotals = {
15 grams: number;
16 subagentGrams: number;
17 subagents: number;
18};
19
20export const HISTORY_SIZE = 200;
21export const MIN_HISTORY = 50;
22// About 1 answer in 27 stands out: above the 97th percentile of the history. The 3x
23// median and the 10 g floor stay as guards: over a flat history the percentile is
24// the median itself.
25export const UNUSUAL_PERCENTILE = 0.97;
26export const UNUSUAL_RATIO = 3;
27export const UNUSUAL_FLOOR_GRAMS = 10;
28export const SUBAGENT_SHARE = 0.1;
29
30const NBSP = " ";
31const UNIT = "CO₂";
32
33// Unset, empty, C and POSIX say nothing about the language.
34function isLocaleValue(value: string | undefined): value is string {
35 return (
36 value !== undefined &&
37 value !== "" &&
38 value !== "C" &&
39 !value.startsWith("C.") &&
40 value !== "POSIX" &&
41 !value.startsWith("POSIX.")
42 );
43}
44
45// Same order and sentinels as detect_equiv_set (scripts/equiv-lib.sh). With no
46// variable set, the language setting decides; with none, French, as in the bash.
47export function detectLang(
48 envValues: Array<string | undefined>,
49 setting?: string,
50): Lang {
51 const value = envValues.find(isLocaleValue);
52 if (value !== undefined) {
53 return value.toLowerCase().startsWith("fr") ? "fr" : "en";
54 }
55 if (setting !== undefined && setting !== "") {
56 return setting.toLowerCase().startsWith("fr") ? "fr" : "en";
57 }
58 return "fr";
59}
60
61// Whether one of the variables decides alone, so nothing else needs to be asked.
62export function hasLangVariable(envValues: Array<string | undefined>): boolean {
63 return envValues.some(isLocaleValue);
64}
65
66// The `language` setting is free text ("Français", "English", "japanese"). It goes
67// to detectLang as a locale would: French when it starts with "fr", English otherwise.
68export function languageSetting(value: unknown): string | undefined {
69 if (typeof value !== "string") return undefined;
70 const text = value.trim();
71 return text === "" ? undefined : text;
72}
73
74function decimal(value: number, digits: number, lang: Lang): string {
75 const text = value.toFixed(digits);
76 return lang === "fr" ? text.replace(".", ",") : text;
77}
78
79// < 0.1 g, one decimal under 10 g, whole grams under 1 kg, then kg with one decimal.
80// Rounding keeps the figure readable; the estimate itself is far less precise than one
81// decimal.
82export function formatGrams(grams: number, lang: Lang): string {
83 if (grams < 0.1) return `<${NBSP}${decimal(0.1, 1, lang)}${NBSP}g${UNIT}`;
84 const tenths = Math.round(grams * 10) / 10;
85 if (tenths < 10) return `${decimal(tenths, 1, lang)}${NBSP}g${UNIT}`;
86 const whole = Math.round(grams);
87 if (whole < 1000) return `${whole}${NBSP}g${UNIT}`;
88 const kilos = Math.round(grams / 100) / 10;
89 return `${decimal(kilos, 1, lang)}${NBSP}kg${UNIT}`;
90}
91
92export function median(values: number[]): number {
93 if (values.length === 0) return 0;
94 const sorted = [...values].sort((a, b) => a - b);
95 const mid = Math.floor(sorted.length / 2);
96 const upper = sorted[mid] ?? 0;
97 return sorted.length % 2 === 1 ? upper : ((sorted[mid - 1] ?? 0) + upper) / 2;
98}
99
100// Linear interpolation between the two closest ranks, q from 0 to 1.
101export function percentile(values: number[], q: number): number {
102 if (values.length === 0) return 0;
103 const sorted = [...values].sort((a, b) => a - b);
104 const rank = (sorted.length - 1) * Math.min(Math.max(q, 0), 1);
105 const lower = Math.floor(rank);
106 const low = sorted[lower] ?? 0;
107 const high = sorted[Math.min(lower + 1, sorted.length - 1)] ?? low;
108 return low + (high - low) * (rank - lower);
109}
110
111// The current answer is compared before it joins the history.
112export function isUnusual(grams: number, history: number[]): boolean {
113 if (history.length < MIN_HISTORY || grams < UNUSUAL_FLOOR_GRAMS) return false;
114 const m = median(history);
115 return (
116 m > 0 &&
117 grams > UNUSUAL_RATIO * m &&
118 grams > percentile(history, UNUSUAL_PERCENTILE)
119 );
120}
121
122export function buildAnswerCo2(
123 turn: TurnTotals,
124 history: number[],
125 lang: Lang,
126): AnswerCo2View {
127 const amount = formatGrams(turn.grams, lang);
128 if (!isUnusual(turn.grams, history))
129 return { main: amount, sub: null, isUnusual: false };
130 const ratio = Math.round(turn.grams / median(history));
131 const main = `${amount} · ${ratio}× ${lang === "fr" ? "votre médiane" : "your median"}`;
132 const share = turn.grams > 0 ? turn.subagentGrams / turn.grams : 0;
133 if (turn.subagents === 0 || share < SUBAGENT_SHARE)
134 return { main, sub: null, isUnusual: true };
135 const n = turn.subagents;
136 const grams = formatGrams(turn.subagentGrams, lang);
137 const sub =
138 lang === "fr"
139 ? `dont ${n} sous-agent${n > 1 ? "s" : ""}${NBSP}: ${grams}`
140 : `incl. ${n} subagent${n > 1 ? "s" : ""}: ${grams}`;
141 return { main, sub, isUnusual: true };
142}
143
144// The single line drawn under the answer where the turn duration line is not
145// (the Desktop app): only ever for an unusual answer.
146export function answerLine(view: AnswerCo2View): string {
147 return view.sub === null
148 ? view.main
149 : `${view.main} · ${view.sub}`;
150}
151
152// Two decimals, as the status line rounds it; "< 0,01 $" under a cent. French puts the
153// sign after the number, English before it.
154export function formatUsd(usd: number, lang: Lang): string {
155 const isTiny = usd < 0.01;
156 const amount = decimal(isTiny ? 0.01 : usd, 2, lang);
157 const money = lang === "fr" ? `${amount}${NBSP}$` : `$${amount}`;
158 return isTiny ? `<${NBSP}${money}` : money;
159}
160
161// The entry pinned under the prompt. The surface already heads it with the plugin's name.
162// The cost is left out when there is none to show (no price list read), never shown as 0.
163export function sessionLine(
164 sessionGrams: number,
165 lang: Lang,
166 sessionUsd: number | null = null,
167): string {
168 const fr = lang === "fr";
169 const head = fr ? `Cette session${NBSP}:` : "This session:";
170 const total = `${head} ${formatGrams(sessionGrams, lang)}`;
171 if (sessionUsd === null || !(sessionUsd > 0)) return total;
172 return `${total} · ${formatUsd(sessionUsd, lang)}`;
173}
174
175// Shown once per machine, the same on every surface.
176export function welcomeText(lang: Lang): string {
177 return lang === "fr"
178 ? "Le CO₂ s'affiche maintenant pour chaque réponse."
179 : "CO₂ now shows for each answer.";
180}
181
182export function pushHistory(history: number[], grams: number): number[] {
183 if (!(grams > 0)) return history;
184 return [...history, grams].slice(-HISTORY_SIZE);
185}
186
187// The claude-carbon status line already shows the session total: no second one.
188export function isCarbonStatusLine(command: unknown): boolean {
189 return (
190 typeof command === "string" &&
191 command.includes("claude-carbon") &&
192 command.includes("statusline.sh")
193 );
194}
195
196// ── The /carbon-panel pane ──────────────────────────────────────────────────
197
198// The pane's tab: the plugin's name, as the person asked for it
199export function paneTitle(_lang: Lang): string {
200 return "claude-carbon";
201}
202
203export function commandDescription(lang: Lang): string {
204 return lang === "fr"
205 ? "Votre molécule de CO₂ et l'historique de vos sessions"
206 : "Your CO₂ molecule and the history of your sessions";
207}
208
209// Shown once per machine to someone already welcomed before the pane existed.
210export function paneHint(lang: Lang): string {
211 return lang === "fr"
212 ? "Tapez /carbon-panel pour voir votre molécule et l'historique de vos sessions."
213 : "Type /carbon-panel to see your molecule and the history of your sessions.";
214}
215
216export function paneUnplaced(lang: Lang): string {
217 return lang === "fr"
218 ? `Le panneau carbone ne peut pas s'ouvrir ici${NBSP}: élargissez la fenêtre ou mettez Claude Code à jour.`
219 : "The carbon pane cannot open here: widen the window or update Claude Code.";
220}
221
222// The molecule's line: the session so far, and its ratio to the median of past sessions.
223export function currentLine(
224 grams: number,
225 sessionMedian: number | null,
226 lang: Lang,
227): string {
228 const fr = lang === "fr";
229 const head = fr ? `Cette session${NBSP}:` : "This session:";
230 const total = `${head} ${formatGrams(grams, lang)}`;
231 if (sessionMedian === null || !(sessionMedian > 0) || !(grams > 0)) return total;
232 const r = grams / sessionMedian;
233 const ratio =
234 r >= 10 ? `${Math.round(r)}` : decimal(r, r >= 1 ? 1 : 2, lang);
235 return `${total} · ${ratio}×${NBSP}${fr ? "votre médiane" : "your median"}`;
236}
237
238// Before 10 past sessions the colour compares with a starting reference (0.5 kgCO₂),
239// said simply to someone who just arrived
240export function colorNote(pastSessions: number, lang: Lang): string {
241 return lang === "fr"
242 ? `Couleur provisoire${NBSP}: elle s'ajustera à vos habitudes après 10 sessions (${pastSessions} pour l'instant).`
243 : `Provisional colour: it adjusts to your habits after 10 sessions (${pastSessions} so far).`;
244}
245
246export function noHistoryText(lang: Lang): string {
247 return lang === "fr"
248 ? "L'historique de vos sessions vient avec l'installation complète (voir le README)."
249 : "The history of your sessions comes with the full install (see the README).";
250}
251
252export function emptyRangeText(lang: Lang): string {
253 return lang === "fr"
254 ? "Aucune session sur cette période."
255 : "No session in this period.";
256}
257
258
259const RANGE_LABELS: Record<string, [string, string]> = {
260 "7d": ["7 j", "7 d"],
261 "30d": ["30 j", "30 d"],
262 "1y": ["12 mois", "12 months"],
263 all: ["Tout", "All"],
264 custom: ["Personnalisée", "Custom"],
265};
266function pick(pair: [string, string] | undefined, lang: Lang): string {
267 return pair === undefined ? "" : lang === "fr" ? pair[0] : pair[1];
268}
269
270export function rangeLabel(range: string, lang: Lang): string {
271 return pick(RANGE_LABELS[range], lang);
272}
273
274
275function countOf(n: number, step: string, lang: Lang): string {
276 const fr = lang === "fr";
277 if (step === "week") return fr ? `${n} semaine${n > 1 ? "s" : ""}` : `${n} week${n > 1 ? "s" : ""}`;
278 if (step === "month") return fr ? `${n} mois` : `${n} month${n > 1 ? "s" : ""}`;
279 return `${n} session${n > 1 ? "s" : ""}`;
280}
281
282// Under the stripes: what one stripe is, and the median their colours measure against
283const PER: Record<string, [string, string]> = {
284 day: ["jour", "day"],
285 week: ["semaine", "week"],
286 month: ["mois", "month"],
287};
288// Before three periods with sessions in them there is nothing to colour: the stripes
289// wait, and say what they will show
290export function stripesWaitText(step: string, filled: number, lang: Lang): string {
291 const fr = lang === "fr";
292 const units: Record<string, [string, string]> = {
293 day: ["jours", "days"],
294 week: ["semaines", "weeks"],
295 month: ["mois", "months"],
296 };
297 const unit = pick(units[step], lang);
298 return fr
299 ? `Vos bandes de couleur apparaissent après 3 ${unit} d'usage (${filled} pour l'instant)${NBSP}: une par période, du bleu au rouge selon son CO₂.`
300 : `Your colour stripes show after 3 ${unit} of use (${filled} so far): one per period, blue to red by its CO₂.`;
301}
302
303export function stripesCaption(step: string, stripeMedian: number | null, lang: Lang): string {
304 const fr = lang === "fr";
305 const unit = pick(PER[step], lang);
306 const one = fr ? `une bande par ${unit}` : `one stripe per ${unit}`;
307 if (stripeMedian === null) return one;
308 const per = fr ? ` par ${unit}` : ` a ${unit}`;
309 return `${one} · ${fr ? "médiane" : "median"} ${formatGrams(stripeMedian, lang)}${per}`;
310}
311
312// The period's figure, the pane's main one
313export function periodHeadline(grams: number, lang: Lang): string {
314 return lang === "fr"
315 ? `${formatGrams(grams, lang)} sur la période`
316 : `${formatGrams(grams, lang)} over the period`;
317}
318
319export function sessionsCount(n: number, lang: Lang): string {
320 return countOf(n, "session", lang);
321}
322
323// The custom range's fields: a date typed day first, or as 2026-05-01
324export function datePlaceholder(lang: Lang): string {
325 return lang === "fr" ? "JJ/MM/AAAA" : "DD/MM/YYYY";
326}
327
328export function dateInvalid(lang: Lang): string {
329 return lang === "fr"
330 ? `Date non reconnue${NBSP}: tapez-la comme 01/05/2026.`
331 : "Date not recognised: type it as 01/05/2026.";
332}
333
334// A day key shown back in its field: "01/05/2026"
335export function dayInputValue(key: string): string {
336 const [y, m, d] = key.split("-");
337 return `${d ?? ""}/${m ?? ""}/${y ?? ""}`;
338}
339
340export function fromLabel(lang: Lang): string {
341 return lang === "fr" ? "Du" : "From";
342}
343
344export function toLabel(lang: Lang): string {
345 return lang === "fr" ? "au" : "to";
346}
347
348export function waitingText(lang: Lang): string {
349 return lang === "fr"
350 ? `Cette session${NBSP}: en attente de la première réponse`
351 : "This session: waiting for the first answer";
352}
353
354
355
356const MONTHS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."];
357const MONTHS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
358
359// A day ("22 août", "Aug 22", with the year when asked), or a month for monthly
360// stripes ("août 2026", "Aug 2026").
361export function dateLabel(
362 ms: number,
363 step: string,
364 lang: Lang,
365 withYear = false,
366): string {
367 const d = new Date(ms);
368 const month = (lang === "fr" ? MONTHS_FR : MONTHS_EN)[d.getMonth()] ?? "";
369 if (step === "month") return `${month}${NBSP}${d.getFullYear()}`;
370 const year = withYear ? `${NBSP}${d.getFullYear()}` : "";
371 return lang === "fr"
372 ? `${d.getDate()}${NBSP}${month}${year}`
373 : `${month}${NBSP}${d.getDate()}${withYear ? `,${NBSP}${d.getFullYear()}` : ""}`;
374}
375
376// A stripe's tooltip in the desktop app.
377export function stripeTitle(
378 start: number,
379 step: string,
380 grams: number,
381 sessions: number,
382 lang: Lang,
383): string {
384 const fr = lang === "fr";
385 const when =
386 step === "week"
387 ? `${fr ? "semaine du" : "week of"} ${dateLabel(start, "session", lang)}`
388 : dateLabel(start, step, lang);
389 const amount = grams > 0 ? formatGrams(grams, lang) : fr ? "aucune session" : "no session";
390 return sessions === 0
391 ? `${when} · ${amount}`
392 : `${when} · ${amount} · ${countOf(sessions, "session", lang)}`;
393}
394
395// Against the same length of time just before: "+12 % par rapport aux 30 jours
396// d'avant". Nothing when there is nothing before to compare with.
397const BEFORE: Record<string, [string, string]> = {
398 "7d": ["aux 7 jours d'avant", "the 7 days before"],
399 "30d": ["aux 30 jours d'avant", "the 30 days before"],
400 "1y": ["aux 12 mois d'avant", "the 12 months before"],
401 custom: ["à la période d'avant", "the period before"],
402};
403
404// The change against the period before, in whole percent; null when there is none
405export function changePercent(grams: number, previous: number): number | null {
406 return previous > 0 ? Math.round(((grams - previous) / previous) * 100) : null;
407}
408export function compareText(
409 grams: number,
410 previous: number,
411 range: string,
412 lang: Lang,
413): string | null {
414 const before = BEFORE[range];
415 const change = changePercent(grams, previous);
416 if (before === undefined || change === null) return null;
417 const sign = change > 0 ? "+" : change < 0 ? "-" : "";
418 const amount = `${sign}${Math.abs(change)}${NBSP}%`;
419 return lang === "fr"
420 ? `${amount} par rapport ${before[0]}`
421 : `${amount} vs ${before[1]}`;
422}
423
424// ── Model families, as the session block names them ─────
425
426const FAMILY_NAMES: Record<string, string> = {
427 fable: "Fable",
428 opus: "Opus",
429 sonnet: "Sonnet",
430 haiku: "Haiku",
431};
432export function familyName(f: string): string {
433 return FAMILY_NAMES[f] ?? f;
434}
435
436
437// The car factor as the pane prints it, the unit spelled out: "142 gCO₂/km, ADEME 2025"
438export function carTag(tag: string): string {
439 return tag.replace(/(\d)\s*g\//, `$1${NBSP}gCO₂/`);
440}
441
442// A subagent's molecule, on hover: "Sous-agent 2 · 12 gCO₂"
443export function subagentTitle(n: number, grams: number, lang: Lang): string {
444 return `${lang === "fr" ? "Sous-agent" : "Subagent"} ${n} · ${formatGrams(grams, lang)}`;
445}
446
447export function subagentsAlt(n: number, lang: Lang): string {
448 return lang === "fr"
449 ? `${n} sous-agent${n > 1 ? "s" : ""} de cette session, une petite molécule chacun, colorée selon son CO₂`
450 : `${n} subagent${n > 1 ? "s" : ""} of this session, a small molecule each, coloured by its CO₂`;
451}
452
453// More subagents than the row shows: "+4"
454export function moreAgentsText(n: number): string {
455 return `+${n}`;
456}
457
458// The context window's fill: "Contexte 42 %"
459export function contextText(percentUsed: number, lang: Lang): string {
460 const p = `${Math.round(percentUsed)}${NBSP}%`;
461 return lang === "fr" ? `Contexte ${p}` : `Context ${p}`;
462}
463
464// The running session, under its line: "12 réponses · 3 sous-agents · Opus"
465export function sessionStatsText(
466 answers: number,
467 subagents: number,
468 f: string | null,
469 lang: Lang,
470): string {
471 const fr = lang === "fr";
472 const parts = [
473 fr ? `${answers} réponse${answers > 1 ? "s" : ""}` : `${answers} answer${answers > 1 ? "s" : ""}`,
474 fr ? `${subagents} sous-agent${subagents > 1 ? "s" : ""}` : `${subagents} subagent${subagents > 1 ? "s" : ""}`,
475 ];
476 if (f !== null) parts.push(familyName(f));
477 return parts.join(" · ");
478}
479
480// The pane's last line: who made it, and the version for a team
481export const AUTHOR_URL = "https://www.linkedin.com/in/gwittebolle/";
482// The French site is at the root; only the English one has a prefix
483export function teamUrl(lang: Lang): string {
484 return lang === "fr"
485 ? "https://tokenclimate.com/?ref=carbon-panel"
486 : "https://tokenclimate.com/en?ref=carbon-panel";
487}
488
489// The project's home: its README says what the tool does and leads to the methodology
490export const REPO_URL = "https://github.com/gwittebolle/claude-carbon";
491export function repoLabel(lang: Lang): string {
492 return lang === "fr" ? "En savoir plus sur claude-carbon" : "Learn more about claude-carbon";
493}
494
495// A newer release, at the top of the foot: what to ask Claude, the plugin command
496export const UPDATE_COMMAND = "claude plugin update claude-carbon@claude-carbon";
497export function updateText(version: string, lang: Lang): string {
498 return lang === "fr"
499 ? `Version ${version} disponible${NBSP}: demandez à Claude de lancer «${NBSP}${UPDATE_COMMAND}${NBSP}», puis redémarrez.`
500 : `Version ${version} is out: ask Claude to run "${UPDATE_COMMAND}", then restart.`;
501}
502
503// The foot's two credit lines, a label and a colon each, the link after it
504export function builtByLabel(lang: Lang): string {
505 return lang === "fr" ? `Développé par${NBSP}:` : "Built by:";
506}
507export function forTeamLabel(lang: Lang): string {
508 return lang === "fr" ? `Découvrez la version entreprise${NBSP}:` : "Discover the business version:";
509}
510
511export function blockTitle(kind: "session" | "usage", lang: Lang): string {
512 if (kind === "session") return lang === "fr" ? "Cette session" : "This session";
513 return lang === "fr" ? "Mon usage" : "My usage";
514}
515
516// The tiles at the top of the desktop pane: a label over a figure
517const TILE_LABELS: Record<string, [string, string]> = {
518 period: ["Sur la période", "Over the period"],
519 change: ["Par rapport à avant", "Against before"],
520 sessions: ["Sessions", "Sessions"],
521 cost: ["Valeur au prix API", "API-price value"],
522 car: ["En voiture", "By car"],
523};
524export function tileLabel(kind: string, lang: Lang): string {
525 return pick(TILE_LABELS[kind], lang);
526}
527
528// "+29 %", "-18 %"
529export function changeValue(change: number): string {
530 const sign = change > 0 ? "+" : change < 0 ? "-" : "";
531 return `${sign}${Math.abs(change)}${NBSP}%`;
532}
533
534// "27 616 $", "$27,616", "12,30 $"
535export function moneyValue(usd: number, lang: Lang): string {
536 return costText(usd, lang).replace(lang === "fr" ? " au prix API" : " at API prices", "");
537}
538
539// Under the value tile, so it holds whoever pays: a subscription, an API key, extra
540// usage, or sessions of several kinds. Which session was billed how is not recorded.
541export function apiValueNote(lang: Lang): string {
542 return lang === "fr"
543 ? `Le prix de cet usage facturé à l'API${NBSP}; un abonnement Pro ou Max en couvre tout ou partie.`
544 : "What this usage costs billed through the API; a Pro or Max subscription covers some or all of it.";
545}
546
547// "12,34 $ au prix API", "$12.34 at API prices"
548export function costText(usd: number, lang: Lang): string {
549 // Whole dollars from a thousand up, grouped: "27 608 $", "$27,608"
550 const money =
551 usd >= 1000
552 ? lang === "fr"
553 ? `${thousands(usd, lang)}${NBSP}$`
554 : `$${thousands(usd, lang)}`
555 : formatUsd(usd, lang);
556 return lang === "fr" ? `${money} au prix API` : `${money} at API prices`;
557}
558
559function thousands(n: number, lang: Lang): string {
560 const whole = String(Math.round(n));
561 const sep = lang === "fr" ? NBSP : ",";
562 return whole.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
563}
564
565// "≈ 1 680 km en voiture (142 g/km, ADEME 2025)", the reports' and cards' car line
566export function equivalenceText(
567 grams: number,
568 car: { divisor: number; label: string; tag: string },
569 lang: Lang,
570): string {
571 return `≈${NBSP}${thousands(grams / car.divisor, lang)} ${car.label} (${carTag(car.tag)})`;
572}
573
574// The car tile's figure, the period's CO2 in the locale's unit: "≈ 1 684 km"
575export function carValue(
576 grams: number,
577 car: { divisor: number; unit: string },
578 lang: Lang,
579): string {
580 const distance = grams / car.divisor;
581 // Under a unit, "≈ 0 km" reads as nothing at all
582 if (distance < 1) return `<${NBSP}1 ${car.unit}`;
583 return `≈${NBSP}${thousands(distance, lang)} ${car.unit}`;
584}
585
586// The blocks a person can show or hide in the pane, under the gear
587
588const BLOCK_LABELS: Record<string, [string, string]> = {
589 compare: ["Comparaison", "Comparison"],
590 cost: ["Coût API", "API cost"],
591 equivalence: ["Équivalence", "Equivalence"],
592 projects: ["Projets", "Projects"],
593 detail: ["Détail", "Detail"],
594};
595export function blockLabel(block: string, lang: Lang): string {
596 return pick(BLOCK_LABELS[block], lang);
597}
598
599// ── The "Détail" block: a period's tokens, energy and CO2 by kind of token ──
600const KIND_LABELS: Record<string, [string, string]> = {
601 input: ["Entrée", "Input"],
602 cacheWrite: ["Cache écrit", "Cache write"],
603 cacheRead: ["Cache relu", "Cache read"],
604 output: ["Sortie", "Output"],
605};
606export function kindLabel(kind: string, lang: Lang): string {
607 return pick(KIND_LABELS[kind], lang);
608}
609
610// Above each bar: what it splits
611export function detailBarLabel(bar: "tokens" | "co2", lang: Lang): string {
612 if (bar === "tokens") return "Tokens";
613 return lang === "fr" ? `${UNIT} et énergie` : `${UNIT} and energy`;
614}
615
616// 820, 12k, 371M, then billions with one decimal: 25,3 Md / 25.3B
617export function formatTokens(n: number, lang: Lang): string {
618 if (n >= 999.5e6)
619 return lang === "fr" ? `${decimal(n / 1e9, 1, lang)}${NBSP}Md` : `${decimal(n / 1e9, 1, lang)}B`;
620 const space = lang === "fr" ? NBSP : "";
621 if (n >= 999.5e3) return `${Math.round(n / 1e6)}${space}M`;
622 if (n >= 1000) return `${Math.round(n / 1e3)}${space}k`;
623 return String(Math.round(n));
624}
625
626// Wh under a kWh, then kWh (one decimal under 100), then MWh
627export function formatWh(wh: number, lang: Lang): string {
628 if (wh < 999.5) return `${Math.round(wh)}${NBSP}Wh`;
629 const kwh = wh / 1000;
630 if (kwh < 99.95) return `${decimal(kwh, 1, lang)}${NBSP}kWh`;
631 if (kwh < 999.5) return `${Math.round(kwh)}${NBSP}kWh`;
632 return `${decimal(kwh / 1000, 1, lang)}${NBSP}MWh`;
633}
634
635// One decimal, "98,4 %", "< 0,1 %" for a share that is there but rounds to nothing,
636// "0 %" for none; a space before % in both languages, as the rest of the pane writes it
637export function formatShare(share: number, lang: Lang): string {
638 if (share <= 0) return `0${NBSP}%`;
639 if (share < 0.0005) return `<${NBSP}${decimal(0.1, 1, lang)}${NBSP}%`;
640 return `${decimal(share * 100, 1, lang)}${NBSP}%`;
641}
642
643// Under a kind's name: "23,8 Md tokens (98,4 %) · 21,6 kWh · 6,2 kgCO₂ (60,9 %)"
644export function detailLine(
645 part: { tokens: number; tokenShare: number; wh: number; grams: number; gramShare: number },
646 lang: Lang,
647): string {
648 return (
649 `${formatTokens(part.tokens, lang)} tokens (${formatShare(part.tokenShare, lang)}) · ` +
650 `${formatWh(part.wh, lang)} · ${formatGrams(part.grams, lang)} (${formatShare(part.gramShare, lang)})`
651 );
652}
653
654export function detailTotalLine(
655 total: { tokens: number; wh: number; grams: number },
656 lang: Lang,
657): string {
658 return (
659 `Total${lang === "fr" ? NBSP : ""}: ${formatTokens(total.tokens, lang)} tokens · ` +
660 `${formatWh(total.wh, lang)} · ${formatGrams(total.grams, lang)}`
661 );
662}
663
664// What a hovered part of a bar says: its kind, its figures on that bar, its share
665// "Cache relu · 23,9 Md tokens · 98,4 %", "Cache relu · 535 kWh · 153,5 kgCO₂ · 60,9 %"
666export function detailHover(
667 part: { kind: string; tokens: number; tokenShare: number; wh: number; grams: number; gramShare: number },
668 bar: "tokens" | "co2",
669 lang: Lang,
670): string {
671 const name = kindLabel(part.kind, lang);
672 return bar === "tokens"
673 ? `${name} · ${formatTokens(part.tokens, lang)} tokens · ${formatShare(part.tokenShare, lang)}`
674 : `${name} · ${formatWh(part.wh, lang)} · ${formatGrams(part.grams, lang)} · ${formatShare(part.gramShare, lang)}`;
675}
676
677// The methodology, straight from the detail
678export const METHODOLOGY_URL =
679 "https://github.com/gwittebolle/claude-carbon/blob/main/METHODOLOGY.md#the-panes-detail-by-kind-of-token";
680export function methodologyLabel(lang: Lang): string {
681 return lang === "fr" ? "Lire la méthodologie" : "Read the methodology";
682}
683
684// When some sessions of the period are left out: stored with older factors, or the
685// running one before its first answer
686export function detailCoverage(n: number, total: number, lang: Lang): string {
687 return lang === "fr"
688 ? `Sur ${n} des ${total} sessions de la période${NBSP}: les autres ont été enregistrées avec des facteurs plus anciens.`
689 : `Over ${n} of the period's ${total} sessions: the others were recorded with older factors.`;
690}
691
692// Before the hook has written the tokens by kind: after the next answer
693export function detailWaitText(lang: Lang): string {
694 return lang === "fr"
695 ? "Le détail s'affichera après la prochaine réponse."
696 : "The detail shows after the next answer.";
697}
698
699export function detailAlt(bar: "tokens" | "co2", lang: Lang): string {
700 return lang === "fr"
701 ? `Répartition ${bar === "tokens" ? "des tokens" : `du ${UNIT}`} entre entrée, cache écrit, cache relu et sortie`
702 : `Share of the ${bar === "tokens" ? "tokens" : UNIT} between input, cache write, cache read and output`;
703}
704
705export function showLabel(lang: Lang): string {
706 return lang === "fr" ? "Afficher" : "Show";
707}
708
709
710// The button that puts /carbon-card in the prompt, for the person to send. The card
711// covers the calendar year so far, whatever range the pane shows: the label says which.
712export function cardButtonLabel(year: number, lang: Lang): string {
713 return lang === "fr" ? `Obtenir ma carte ${year}` : `Get my ${year} card`;
714}
715
716// After the button filled the prompt: what is there and what to do
717export function cardReadyText(lang: Lang): string {
718 return lang === "fr"
719 ? `/carbon-card est dans le prompt${NBSP}: appuyez sur Entrée pour créer la carte.`
720 : "/carbon-card is in the prompt: press Enter to make the card.";
721}
722
723// The prompt could not take it (a dialog is open, or no prompt box here)
724export function cardFillFailed(lang: Lang): string {
725 return lang === "fr"
726 ? `Le prompt n'a pas pu recevoir /carbon-card${NBSP}: tapez-le vous-même.`
727 : "The prompt could not take /carbon-card: type it yourself.";
728}
729
730// When the prompt already holds a draft, the button leaves it alone and says why
731export function cardDraftBusy(lang: Lang): string {
732 return lang === "fr"
733 ? `Le prompt contient déjà un brouillon${NBSP}: videz-le pour obtenir la carte.`
734 : "The prompt already holds a draft: clear it to get the card.";
735}
736
737
738function percent(share: number): string {
739 const p = Math.round(share * 100);
740 return p < 1 ? `<${NBSP}1${NBSP}%` : `${p}${NBSP}%`;
741}
742
743
744// A project as a bar, left-aligned so bars compare whatever the font:
745// "███████ 41 % carbonara · 103,5 kgCO₂"
746export function projectBarLine(
747 project: string,
748 grams: number,
749 share: number,
750 lang: Lang,
751): string {
752 const name = project === "" ? (lang === "fr" ? "sans projet" : "no project") : project;
753 const bar = "█".repeat(Math.max(1, Math.round(share * 16)));
754 return `${bar} ${percent(share)} ${name} · ${formatGrams(grams, lang)}`;
755}
756
757// The rest after the projects listed
758export function otherProjectsLine(count: number, grams: number, share: number, lang: Lang): string {
759 const fr = lang === "fr";
760 const label = fr
761 ? `${count} autre${count > 1 ? "s" : ""} projet${count > 1 ? "s" : ""}`
762 : `${count} other project${count > 1 ? "s" : ""}`;
763 return `${label} · ${formatGrams(grams, lang)} · ${percent(share)}`;
764}
765
766export function mascotAlt(lang: Lang): string {
767 return lang === "fr"
768 ? "Molécule de CO₂ en pixels, colorée selon cette session par rapport à votre médiane, du bleu (en dessous) au rouge (au-dessus)"
769 : "Pixel CO₂ molecule, coloured by this session against your median, from blue (below) to red (above)";
770}
771
772// The scale under the molecule, read aloud: where this session sits on it
773export function scaleAlt(cls: number | null, lang: Lang): string {
774 const fr = lang === "fr";
775 if (cls === null)
776 return fr
777 ? "Échelle des couleurs, du bleu (sous votre médiane) au rouge (au-dessus)"
778 : "Colour scale, from blue (under your median) to red (over it)";
779 return fr
780 ? `Échelle des couleurs, du bleu au rouge : cette session est en ${cls + 1}e position sur 8`
781 : `Colour scale, from blue to red: this session is ${cls + 1} of 8`;
782}
783
784export function stripesAlt(lang: Lang): string {
785 return lang === "fr"
786 ? "Une bande par période, la plus ancienne à gauche, du bleu (sous la médiane) au rouge (au-dessus)"
787 : "One stripe per period, oldest on the left, from blue (below the median) to red (above)";
788}
789hooks/mod/lib/lines.ts 52 lines1// Which turn a duration line belongs to. Pure: no Claude Code API, so Node runs it
2// directly (tests/run-mod-tests.mjs).
3
4// A line drawn for the first time this close to a turn's end is that turn's line.
5// Measured in a terminal: 8 to 13 ms from turn.complete to the line's first draw.
6export const BIND_WINDOW_MS = 1000;
7
8// What the rules read of an entry. An entry written before the turnId was kept has
9// none and is never bound.
10export type LineEntry = {
11 turnId?: unknown;
12 at?: unknown;
13};
14
15export type LineMatch<E> = { entry: E; isNew: boolean } | undefined;
16
17// The lines bound so far: each line's turn, and the turns that have a line.
18export type Bound = {
19 turnOf: ReadonlyMap<string, string>;
20 taken: ReadonlySet<string>;
21};
22
23// 1. The entry of the turn this line was bound to. 2. A line drawn for the first
24// time within a second of a turn's end takes the newest such entry no line holds
25// yet. 3. None: the engine's line stays as it is. The duration is never compared:
26// after background agents the line can show another one than the turn reports.
27export function lineEntry<E extends LineEntry>(
28 entries: readonly E[],
29 requestId: string,
30 firstDrawnAt: number,
31 bound: Bound,
32): LineMatch<E> {
33 const turnId = bound.turnOf.get(requestId);
34 if (turnId !== undefined) {
35 const entry = entries.find((x) => x.turnId === turnId);
36 return entry === undefined ? undefined : { entry, isNew: false };
37 }
38 for (let i = entries.length - 1; i >= 0; i -= 1) {
39 const entry = entries[i];
40 if (
41 entry !== undefined &&
42 typeof entry.turnId === "string" &&
43 typeof entry.at === "number" &&
44 !bound.taken.has(entry.turnId) &&
45 Math.abs(firstDrawnAt - entry.at) < BIND_WINDOW_MS
46 ) {
47 return { entry, isNew: true };
48 }
49 }
50 return undefined;
51}
52hooks/mod/lib/mascot.ts 620 lines1// The mascot, a pixel CO2 molecule (O=C=O: the carbon in the middle carries the eyes),
2// and the warming stripes, drawn for the /carbon-panel pane: SVGs in the desktop app, the
3// molecule alone in terminal cells (Raster) in a terminal. docs/mascot-stripes.svg,
4// the README banner, is drawn from the same frames and palette (scripts/render-mascot.mjs).
5// Pure: no Claude Code API, so Node runs it directly (tests/run-mod-tests.mjs).
6
7// 18 x 14 pixels: # body. Frame B is the hop: the whole molecule one pixel up, its legs
8// apart; the two alternate into a bouncing walk. Both frames are the same size, as a
9// blit wants. The face is drawn over the body (FACES): k eyes and mouth, s a drop of
10// sweat, z a sleeper's z.
11const BODY: readonly string[] = [
12 ".......####.......",
13 "......######......",
14 ".##...######...##.",
15 "##################",
16 "####..######..####",
17 "##################",
18 ".##...######...##.",
19 "......######......",
20 ".......####.......",
21];
22const EMPTY_ROW = "..................";
23// Where BODY's first row sits in each hop frame
24const HOP_TOP = [2, 1] as const;
25const HOPS: readonly (readonly string[])[] = [
26 [EMPTY_ROW, EMPTY_ROW, ...BODY, ".......#..#.......", ".......#..#.......", EMPTY_ROW],
27 [EMPTY_ROW, ...BODY, "......#....#......", "......#....#......", EMPTY_ROW, EMPTY_ROW],
28];
29
30// The terminal's molecule, small enough for an editor's terminal: 13 x 8 pixels, four
31// rows of half blocks, about the size of the engine's own welcome mascot. No outline.
32// Frame B hops one pixel, half a terminal row, legs apart.
33const MINI_BODY: readonly string[] = [
34 "....#####....",
35 ".##.#####.##.",
36 "#############",
37 ".##.#####.##.",
38 "....#####....",
39];
40const MINI_EMPTY = ".............";
41const MINI_HOPS: readonly (readonly string[])[] = [
42 [MINI_EMPTY, MINI_EMPTY, ...MINI_BODY, ".....#.#....."],
43 [MINI_EMPTY, ...MINI_BODY, "....#...#....", MINI_EMPTY],
44];
45
46// The molecule's faces. Each is a set of marks in BODY's coordinates (x, y), the same
47// names for the big molecule and the terminal's small one.
48export type Face =
49 | "neutral"
50 | "blink"
51 | "lookLeft"
52 | "lookRight"
53 | "lookUp"
54 | "wink"
55 | "happy"
56 | "surprised"
57 | "worried"
58 | "worriedBlink"
59 | "sleep"
60 | "doze";
61// k eyes and mouth, s sweat, z a Z in pixels; g a cell of the terminal's molecule that
62// shows the letter z instead (a Z of a few pixels does not read there)
63type Marks = {
64 k: readonly (readonly [number, number])[];
65 s?: readonly (readonly [number, number])[];
66 z?: readonly (readonly [number, number])[];
67 g?: readonly (readonly [number, number])[];
68};
69// A Z of 4 x 4 with its diagonal, above the right oxygen: room the first hop alone has,
70// so the molecule asleep does not hop
71const Z = [
72 [14, -2], [15, -2], [16, -2], [17, -2],
73 [16, -1],
74 [15, 0],
75 [14, 1], [15, 1], [16, 1], [17, 1],
76] as const;
77const DROP = [[13, 0], [13, 1]] as const;
78const SMILE = [[7, 6], [8, 7], [9, 7], [10, 6]] as const;
79const FLAT = [[8, 6], [9, 6]] as const;
80const EYES = [[7, 3], [7, 4], [10, 3], [10, 4]] as const;
81const SHUT = [[7, 4], [10, 4]] as const;
82const FACES: Record<Face, Marks> = {
83 neutral: { k: EYES },
84 blink: { k: SHUT },
85 lookLeft: { k: [[6, 3], [6, 4], [9, 3], [9, 4]] },
86 lookRight: { k: [[8, 3], [8, 4], [11, 3], [11, 4]] },
87 lookUp: { k: [[7, 2], [7, 3], [10, 2], [10, 3]] },
88 wink: { k: [[7, 3], [7, 4], [10, 4], ...SMILE] },
89 happy: { k: [[7, 3], [6, 4], [8, 4], [10, 3], [9, 4], [11, 4], ...SMILE] },
90 surprised: { k: [[7, 2], [7, 3], [7, 4], [10, 2], [10, 3], [10, 4], [8, 6], [9, 6], [8, 7], [9, 7]] },
91 worried: { k: [...EYES, ...FLAT], s: DROP },
92 worriedBlink: { k: [...SHUT, ...FLAT], s: DROP },
93 sleep: { k: SHUT, z: Z },
94 doze: { k: SHUT },
95};
96const MINI_SMILE = [[5, 3], [6, 4], [7, 3]] as const;
97const MINI_EYES = [[5, 1], [7, 1]] as const;
98const MINI_FACES: Record<Face, Marks> = {
99 neutral: { k: MINI_EYES },
100 // One row of eyes: shut, they are gone for a moment
101 blink: { k: [] },
102 lookLeft: { k: [[4, 1], [6, 1]] },
103 lookRight: { k: [[6, 1], [8, 1]] },
104 lookUp: { k: [[5, 0], [7, 0]] },
105 wink: { k: [[5, 1], ...MINI_SMILE] },
106 happy: { k: [...MINI_EYES, ...MINI_SMILE] },
107 surprised: { k: [[5, 0], [5, 1], [7, 0], [7, 1], [6, 3], [6, 4]] },
108 worried: { k: [...MINI_EYES, [5, 3], [6, 3], [7, 3]], s: [[10, 0], [10, -1]] },
109 worriedBlink: { k: [[5, 3], [6, 3], [7, 3]], s: [[10, 0], [10, -1]] },
110 sleep: { k: [], g: [[10, -2], [12, -2]] },
111 doze: { k: [] },
112};
113
114function withMarks(hop: readonly string[], top: number, marks: Marks): string[] {
115 const rows = hop.map((r) => [...r]);
116 const put = (list: Marks["k"] | undefined, c: string) =>
117 list?.forEach(([x, y]) => {
118 const row = rows[y + top];
119 if (row !== undefined && x >= 0 && x < row.length) row[x] = c;
120 });
121 put(marks.k, "k");
122 put(marks.s, "s");
123 put(marks.z, "z");
124 put(marks.g, "g");
125 return rows.map((r) => r.join(""));
126}
127
128// A hop frame (0 or 1) with a face, big or small
129export function faceFrame(hop: 0 | 1, face: Face): string[] {
130 return withMarks(HOPS[hop] ?? HOPS[0]!, HOP_TOP[hop], FACES[face]);
131}
132export function miniFaceFrame(hop: 0 | 1, face: Face): string[] {
133 return withMarks(MINI_HOPS[hop] ?? MINI_HOPS[0]!, HOP_TOP[hop], MINI_FACES[face]);
134}
135
136export const FRAME_A: readonly string[] = faceFrame(0, "neutral");
137export const FRAME_B: readonly string[] = faceFrame(1, "neutral");
138export const FRAMES = [FRAME_A, FRAME_B] as const;
139export const MINI_FRAMES = [miniFaceFrame(0, "neutral"), miniFaceFrame(1, "neutral")] as const;
140
141// What the molecule is up to, and the faces it pulls meanwhile, in a loop: each face
142// with how long it holds (ms).
143export type Mood =
144 | "idle"
145 | "waiting"
146 | "working"
147 | "cheerful"
148 | "strained"
149 | "pleased"
150 | "phew"
151 | "asleep";
152export const MOODS: Record<Mood, ReadonlyArray<readonly [Face, number]>> = {
153 idle: [
154 ["neutral", 2400], ["blink", 150], ["neutral", 1700], ["lookLeft", 800],
155 ["neutral", 250], ["lookRight", 800], ["neutral", 1500], ["wink", 500],
156 ["neutral", 1300], ["lookUp", 700], ["neutral", 1900], ["blink", 120],
157 ["neutral", 140], ["blink", 120], ["neutral", 1100], ["happy", 1300],
158 ],
159 // No answer yet: it looks around
160 waiting: [
161 ["lookLeft", 900], ["neutral", 500], ["lookRight", 900], ["neutral", 600],
162 ["lookUp", 700], ["blink", 150], ["neutral", 1200],
163 ],
164 working: [
165 ["neutral", 1300], ["blink", 120], ["neutral", 900], ["lookRight", 450],
166 ["neutral", 1100], ["lookLeft", 450], ["neutral", 800], ["blink", 120],
167 ["neutral", 600], ["lookUp", 400],
168 ],
169 // A light session at work
170 cheerful: [
171 ["happy", 900], ["neutral", 900], ["blink", 120], ["neutral", 700],
172 ["happy", 700], ["lookRight", 400], ["neutral", 500],
173 ],
174 // A heavy session at work: it sweats
175 strained: [
176 ["worried", 1300], ["worriedBlink", 120], ["worried", 900], ["surprised", 400],
177 ["worried", 1100], ["worriedBlink", 120],
178 ],
179 // Right after an answer
180 pleased: [["happy", 2400]],
181 phew: [["surprised", 700], ["worriedBlink", 150], ["worried", 1550]],
182 asleep: [["sleep", 1400], ["doze", 1000]],
183};
184
185// A mood in which the molecule does not hop
186export function isStill(mood: Mood): boolean {
187 return mood === "asleep";
188}
189
190// How long the molecule reacts to an answer, and how long it stays idle before it sleeps
191export const REACT_MS = 2400;
192export const SLEEP_AFTER_MS = 5 * 60_000;
193
194export function moodOf(o: {
195 working: boolean;
196 cls: number | null;
197 hasAnswer: boolean;
198 // Since the last turn started or ended, ms
199 idleMs: number;
200 // Within REACT_MS of an answer
201 reacting: boolean;
202}): Mood {
203 const heavy = o.cls !== null && o.cls >= 6;
204 const light = o.cls !== null && o.cls <= 1;
205 if (o.working) return heavy ? "strained" : light ? "cheerful" : "working";
206 if (o.reacting) return heavy ? "phew" : "pleased";
207 if (!o.hasAnswer) return "waiting";
208 return o.idleMs >= SLEEP_AFTER_MS ? "asleep" : "idle";
209}
210
211// The face `t` ms into a mood's loop, and how long it holds from there
212export function faceAt(mood: Mood, t: number): { face: Face; holdMs: number } {
213 const loop = MOODS[mood];
214 const total = loop.reduce((sum, [, ms]) => sum + ms, 0);
215 let at = ((t % total) + total) % total;
216 for (const [face, ms] of loop) {
217 if (at < ms) return { face, holdMs: ms - at };
218 at -= ms;
219 }
220 const first = loop[0];
221 return { face: first?.[0] ?? "neutral", holdMs: first?.[1] ?? total };
222}
223
224// Dark blue to dark red, one colour per power of two around the median: the bluest under
225// an eighth of it, the reddest over eight times it.
226export const PALETTE = [
227 "#08306b",
228 "#2166ac",
229 "#6aa6d0",
230 "#c6dbef",
231 "#fcbba1",
232 "#ef6548",
233 "#c41a1d",
234 "#67000d",
235] as const;
236// Too little history to compare against: the molecule stays grey.
237export const NEUTRAL = "#8b949e";
238// The outline that keeps the darkest colours visible on a dark background (the SVGs).
239export const OUTLINE = "#8b949e";
240const EYES_DARK = "#0b0b0b";
241const EYES_LIGHT = "#d9dce3";
242
243export const SPRITE_WIDTH = 20;
244export const SPRITE_HEIGHT = 16;
245// The terminal's Raster: two pixel rows per terminal row (half blocks)
246export const CELL_COLUMNS = 13;
247export const CELL_ROWS = 4;
248
249export function classOf(grams: number, median: number): number {
250 if (!(median > 0) || !(grams > 0)) return 0;
251 const cls = Math.floor(Math.log2(grams / median)) + 4;
252 return Math.min(7, Math.max(0, cls));
253}
254
255// At rest the molecule still breathes: a slow bob, one frame every 1.2 s
256export const IDLE_STEP_MS = 1200;
257
258// One step of the walk: 460 ms for the bluest, 124 ms for the reddest.
259export function stepMs(cls: number): number {
260 return 460 - Math.min(7, Math.max(0, cls)) * 48;
261}
262
263export function colorOf(cls: number | null): string {
264 return cls === null ? NEUTRAL : (PALETTE[cls] ?? NEUTRAL);
265}
266
267export function eyesFor(color: string): string {
268 return color === PALETTE[0] || color === PALETTE[7] ? EYES_LIGHT : EYES_DARK;
269}
270
271// A drop of sweat, and a sleeper's z
272export const SWEAT = "#4f9fd8";
273export const ZED = OUTLINE;
274
275type Pixel = "body" | "eye" | "sweat" | "zed" | "outline" | null;
276const KIND: Record<string, Pixel> = { "#": "body", k: "eye", s: "sweat", z: "zed" };
277
278// The frame on a 20 x 16 grid: one pixel of outline around the 18 x 14 drawing (around
279// the body and the face, not around a drop or a z).
280export function pixels(frame: readonly string[]): Pixel[][] {
281 const grid: Pixel[][] = Array.from({ length: SPRITE_HEIGHT }, () =>
282 Array.from({ length: SPRITE_WIDTH }, () => null as Pixel),
283 );
284 frame.forEach((row, y) =>
285 [...row].forEach((c, x) => {
286 const line = grid[y + 1];
287 if (line === undefined) return;
288 line[x + 1] = KIND[c] ?? null;
289 }),
290 );
291 for (let y = 0; y < SPRITE_HEIGHT; y++)
292 for (let x = 0; x < SPRITE_WIDTH; x++) {
293 if (grid[y]?.[x] !== null) continue;
294 const near = [
295 [1, 0],
296 [-1, 0],
297 [0, 1],
298 [0, -1],
299 ].some(([dx, dy]) => {
300 const v = grid[y + (dy ?? 0)]?.[x + (dx ?? 0)];
301 return v === "body" || v === "eye";
302 });
303 const line = grid[y];
304 if (near && line !== undefined) line[x] = "outline";
305 }
306 return grid;
307}
308
309function rects(grid: Pixel[][], kind: Pixel, px: number, ox = 0): string {
310 let d = "";
311 grid.forEach((row, y) =>
312 row.forEach((v, x) => {
313 if (v === kind) d += `M${ox + x * px} ${y * px}h${px}v${px}h-${px}z`;
314 }),
315 );
316 return d;
317}
318
319function escapeXml(text: string): string {
320 return text
321 .replaceAll("&", "&")
322 .replaceAll("<", "<")
323 .replaceAll(">", ">")
324 .replaceAll('"', """);
325}
326
327function path(fill: string, d: string, extra = ""): string {
328 return d === "" ? "" : `<path fill="${fill}"${extra} d="${d}"/>`;
329}
330
331// One hop of a molecule: its outline and body, then each face it may pull, stacked and
332// hidden but for the one the animation shows. The body is the same under every face.
333export function moleculeHop(
334 hop: 0 | 1,
335 color: string,
336 faces: readonly Face[],
337 px: number,
338 ox: number,
339): string {
340 const bare = pixels(withMarks(HOPS[hop] ?? HOPS[0]!, HOP_TOP[hop], { k: [] }));
341 const face = (f: Face) => {
342 const grid = pixels(faceFrame(hop, f));
343 return (
344 `<g class="f f-${f}">` +
345 path(eyesFor(color), rects(grid, "eye", px, ox)) +
346 path(SWEAT, rects(grid, "sweat", px, ox)) +
347 path(ZED, rects(grid, "zed", px, ox)) +
348 `</g>`
349 );
350 };
351 return (
352 `<g class="${hop === 0 ? "a" : "b"}">` +
353 path(OUTLINE, rects(bare, "outline", px, ox), ` fill-opacity="0.45"`) +
354 path(color, rects(bare, "body", px, ox)) +
355 faces.map(face).join("") +
356 `</g>`
357 );
358}
359
360// The CSS that walks or breathes (the two hops) and runs a mood's faces in a loop. Under
361// reduced motion: the first hop and the mood's first face, still. With a scope (a class
362// on the molecule's group), several molecules in one drawing each keep their own mood
363// and phase: the README's banner and its row of moods.
364export function moleculeCss(
365 mood: Mood,
366 hopPeriod: number,
367 opts: { scope?: string; hopDelay?: number; faceDelay?: number } = {},
368): { css: string; faces: Face[] } {
369 const loop = MOODS[mood];
370 const faces = [...new Set(loop.map(([f]) => f))];
371 const total = loop.reduce((sum, [, ms]) => sum + ms, 0);
372 const p = opts.scope !== undefined ? `.${opts.scope} ` : "";
373 const k = opts.scope !== undefined ? `${opts.scope}-` : "";
374 const hopDelay = opts.hopDelay ? ` -${opts.hopDelay}ms` : "";
375 const faceDelay = opts.faceDelay ? ` -${opts.faceDelay}ms` : "";
376 const css = [`${p}.b{opacity:0}${p}.f{opacity:0}`];
377 // Asleep it lies still: its Z takes the room the hop would
378 if (!isStill(mood))
379 css.push(
380 `@keyframes fa{0%{opacity:1}50%{opacity:0}100%{opacity:0}}`,
381 `@keyframes fb{0%{opacity:0}50%{opacity:1}100%{opacity:1}}`,
382 `${p}.a{animation:fa ${hopPeriod}ms steps(1,end) infinite${hopDelay}}`,
383 `${p}.b{animation:fb ${hopPeriod}ms steps(1,end) infinite${hopDelay}}`,
384 );
385 if (faces.length === 1) {
386 css.push(`${p}.f-${faces[0]}{opacity:1}`);
387 } else {
388 for (const f of faces) {
389 let at = 0;
390 const stops: string[] = [];
391 for (const [g, ms] of loop) {
392 stops.push(`${((at / total) * 100).toFixed(3)}%{opacity:${g === f ? 1 : 0}}`);
393 at += ms;
394 }
395 const last = loop[loop.length - 1];
396 stops.push(`100%{opacity:${last !== undefined && last[0] === f ? 1 : 0}}`);
397 css.push(
398 `@keyframes k-${k}${f}{${stops.join("")}}`,
399 `${p}.f-${f}{animation:k-${k}${f} ${total}ms steps(1,end) infinite${faceDelay}}`,
400 );
401 }
402 }
403 css.push(
404 `@media (prefers-reduced-motion: reduce){${p}.a,${p}.b,${p}.f{animation:none}${p}.a{opacity:1}${p}.b{opacity:0}${p}.f{opacity:0}${p}.f-${faces[0] ?? "neutral"}{opacity:1}}`,
405 );
406 return { css: css.join(""), faces };
407}
408
409// The molecule alone. Walking, it alternates its two hops at `step` ms; at rest it bobs
410// slowly, IDLE_STEP_MS a hop. Its face follows its mood. No script: CSS animations.
411export function mascotSvg(opts: {
412 color: string;
413 walking: boolean;
414 step: number;
415 title: string;
416 mood?: Mood;
417}): string {
418 const w = SPRITE_WIDTH * 10;
419 const h = SPRITE_HEIGHT * 10;
420 const period = (opts.walking ? opts.step : IDLE_STEP_MS) * 2;
421 const { css, faces } = moleculeCss(opts.mood ?? (opts.walking ? "working" : "idle"), period);
422 return (
423 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">` +
424 `<title>${escapeXml(opts.title)}</title><style>${css}</style>` +
425 moleculeHop(0, opts.color, faces, 10, 0) +
426 moleculeHop(1, opts.color, faces, 10, 0) +
427 `</svg>`
428 );
429}
430
431export type Stripe = {
432 // null: an empty period, drawn in the empty colour
433 color: string | null;
434 // Shown on hover in the desktop app: the period and its total
435 title: string;
436 // Start of the period, ms since the epoch
437 start?: number;
438};
439
440// The running session's subagents, a small molecule each, in a row under the big one,
441// at 3 px a pixel, each with its own colour and a <title> (its CO2). They move and pull
442// faces like the big one (working or idle, never its sweat), each a little out of step.
443export const SUBAGENT_PX = 3;
444export function subagentsSvg(
445 agents: ReadonlyArray<{ color: string; title: string }>,
446 opts: { walking: boolean; step: number },
447): string {
448 const w = SPRITE_WIDTH * SUBAGENT_PX;
449 const h = SPRITE_HEIGHT * SUBAGENT_PX;
450 const gap = 6;
451 const period = (opts.walking ? opts.step : IDLE_STEP_MS) * 2;
452 const { css, faces } = moleculeCss(opts.walking ? "working" : "idle", period);
453 const delays: string[] = [];
454 const items = agents
455 .map((a, i) => {
456 const ox = i * (w + gap);
457 // Out of step with each other, so they do not move as one
458 delays.push(`.m${i} .a,.m${i} .b,.m${i} .f{animation-delay:-${(i * 1170) % 9000}ms}`);
459 return (
460 `<g class="m${i}"><title>${escapeXml(a.title)}</title>` +
461 moleculeHop(0, a.color, faces, SUBAGENT_PX, ox) +
462 moleculeHop(1, a.color, faces, SUBAGENT_PX, ox) +
463 `</g>`
464 );
465 })
466 .join("");
467 const total = Math.max(1, agents.length) * (w + gap) - gap;
468 return (
469 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${h}" width="${total}" height="${h}" shape-rendering="crispEdges">` +
470 `<style>${css}${delays.join("")}</style>${items}</svg>`
471 );
472}
473
474// The colour scale under the molecule: the eight colours, bluest to reddest, and a marker
475// under this session's. No marker while the molecule is grey (no median yet).
476export const SCALE_WIDTH = 112;
477export const SCALE_HEIGHT = 22;
478export function scaleSvg(cls: number | null): string {
479 const swatch = SCALE_WIDTH / PALETTE.length;
480 const swatches = PALETTE.map(
481 (c, i) => `<rect x="${(i * swatch).toFixed(2)}" y="0" width="${swatch.toFixed(2)}" height="8" fill="${c}"/>`,
482 ).join("");
483 const marker =
484 cls === null
485 ? ""
486 : (() => {
487 const x = (Math.min(7, Math.max(0, cls)) + 0.5) * swatch;
488 return `<path fill="${OUTLINE}" d="M${x.toFixed(2)} 11l5 8h-10z"/>`;
489 })();
490 return (
491 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SCALE_WIDTH} ${SCALE_HEIGHT}" width="${SCALE_WIDTH}" height="${SCALE_HEIGHT}" shape-rendering="crispEdges">` +
492 `${swatches}${marker}</svg>`
493 );
494}
495
496export const STRIPES_HEIGHT = 72;
497// The hovered stripe's label shows over the stripes' foot: no room kept for it
498export const STRIPES_SVG_HEIGHT = STRIPES_HEIGHT;
499
500// A hover label, shared by the stripes and the detail's bars: a dark box with white
501// text, readable on a light or a dark pane, centred on `centre` and kept inside `width`
502// (half a pixel in, so its outline is not cut). About 6 px a character at 12 px; a text
503// too long for the drawing is set smaller, down to 8 px, rather than cut.
504export const HOVER_LABEL_HEIGHT = 22;
505export function hoverLabelSvg(o: {
506 id: string;
507 cls: string;
508 text: string;
509 centre: number;
510 y: number;
511 width: number;
512}): string {
513 const natural = o.text.length * 6;
514 const size = natural + 14 <= o.width - 1 ? 12 : Math.max(8, (12 * (o.width - 15)) / natural);
515 const boxW = Math.min(o.width - 1, (natural * size) / 12 + 14);
516 const boxX = Math.max(0.5, Math.min(o.width - 0.5 - boxW, o.centre - boxW / 2));
517 return (
518 `<g id="${o.id}" class="${o.cls}"><rect x="${boxX.toFixed(2)}" y="${o.y}" width="${boxW.toFixed(2)}" height="${HOVER_LABEL_HEIGHT}" rx="4" fill="#1f2328" stroke="${OUTLINE}" stroke-opacity="0.6"/>` +
519 `<text x="${(boxX + 7).toFixed(2)}" y="${o.y + 15}" font-size="${Number(size.toFixed(1))}" font-family="system-ui,-apple-system,sans-serif" fill="#ffffff">${escapeXml(o.text)}</text></g>`
520 );
521}
522
523// One rect per stripe across `width` CSS pixels (the viewBox matches the drawn size, so
524// labels keep their shape), oldest on the left; an empty period is a faint grey. All the
525// stripes come first, then all the labels, hidden, in a layer above them: hovering
526// stripe i lights it up and shows label i at once over the stripes' foot, in a dark box
527// readable on a light or a dark pane, never under the next stripe. A CSS :hover and the
528// sibling selector, no script, and no <title>: the app's own tooltip would show a second
529// label late. The Svg's alt says what the drawing is.
530export function stripesSvg(
531 stripes: readonly Stripe[],
532 _title: string,
533 width: number,
534): string {
535 const n = Math.max(1, stripes.length);
536 const sw = width / n;
537 const h = STRIPES_HEIGHT;
538 const rects = stripes
539 .map((s, i) => {
540 const fill = s.color === null ? `fill="${OUTLINE}" fill-opacity="0.2"` : `fill="${s.color}"`;
541 return `<rect id="b${i}" class="b" x="${(i * sw).toFixed(2)}" y="0" width="${sw.toFixed(2)}" height="${h}" ${fill}/>`;
542 })
543 .join("");
544 const labels = stripes
545 .map((s, i) =>
546 hoverLabelSvg({ id: `t${i}`, cls: "t", text: s.title, centre: i * sw + sw / 2, y: h - 26, width }),
547 )
548 .join("");
549 const show = stripes.map((_, i) => `#b${i}:hover~#t${i}`).join(",");
550 const style =
551 `<style>.t{opacity:0;pointer-events:none}` +
552 `.b:hover{opacity:.7;stroke:#ffffff;stroke-width:2}` +
553 (stripes.length > 0 ? `${show}{opacity:1}` : "") +
554 `</style>`;
555 return (
556 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${STRIPES_SVG_HEIGHT}" width="${width}" height="${STRIPES_SVG_HEIGHT}">` +
557 `${style}${rects}${labels}</svg>`
558 );
559}
560
561// ── Terminal cells (Raster) ─────────────────────────────────────────────────
562
563const DEFAULT_COLOR = 0x01000000;
564const UPPER_HALF = 0x2580; // ▀
565const LOWER_HALF = 0x2584; // ▄
566const LETTER_Z = 0x7a; // z
567const SPACE = 0x20;
568
569function rgb(hex: string): number {
570 return Number.parseInt(hex.slice(1), 16) & 0xffffff;
571}
572
573// Standard padded base64 of little-endian u32 triplets, as RasterProps.cells wants.
574// No Uint8Array.toBase64: Node 22, where the pure tests run, does not have it.
575export function encodeCells(words: readonly number[]): string {
576 const bytes = new Uint8Array(words.length * 4);
577 const view = new DataView(bytes.buffer);
578 words.forEach((w, i) => view.setUint32(i * 4, w >>> 0, true));
579 let binary = "";
580 const CHUNK = 0x8000;
581 for (let i = 0; i < bytes.length; i += CHUNK) {
582 binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
583 }
584 return btoa(binary);
585}
586
587// One triplet per cell, two pixels a cell: the top pixel in the foreground of an upper
588// half block, the bottom one in its background. A transparent pixel is the terminal's
589// default colour, so a cell with only its bottom pixel takes a lower half block instead.
590export function mascotWords(frame: readonly string[], color: string): number[] {
591 const paint = (c: string | undefined): number | null =>
592 c === "#"
593 ? rgb(color)
594 : c === "k"
595 ? rgb(eyesFor(color))
596 : c === "s"
597 ? rgb(SWEAT)
598 : c === "z"
599 ? rgb(ZED)
600 : null;
601 const words: number[] = [];
602 for (let row = 0; row < CELL_ROWS; row++)
603 for (let x = 0; x < CELL_COLUMNS; x++) {
604 if (frame[row * 2]?.[x] === "g" || frame[row * 2 + 1]?.[x] === "g") {
605 words.push(LETTER_Z, rgb(ZED), DEFAULT_COLOR);
606 continue;
607 }
608 const top = paint(frame[row * 2]?.[x]);
609 const bottom = paint(frame[row * 2 + 1]?.[x]);
610 if (top !== null) words.push(UPPER_HALF, top, bottom ?? DEFAULT_COLOR);
611 else if (bottom !== null) words.push(LOWER_HALF, bottom, DEFAULT_COLOR);
612 else words.push(SPACE, DEFAULT_COLOR, DEFAULT_COLOR);
613 }
614 return words;
615}
616
617export function mascotCells(frame: readonly string[], color: string): string {
618 return encodeCells(mascotWords(frame, color));
619}
620hooks/mod/lib/sessions.ts 428 lines1// The session history the /carbon-panel pane draws: read from sessions-history.tsv, which
2// the Stop hook writes next to carbon.db (cc_export_sessions in scripts/portable-lib.sh).
3// The mod never opens the database. Pure: no Claude Code API, so Node runs it directly
4// (tests/run-mod-tests.mjs).
5
6import { median } from "./format.ts";
7
8export type SessionRow = {
9 id: string;
10 // Start of the session, ms since the epoch
11 at: number;
12 grams: number;
13 // Methodology version: 1 for legacy rows, kept as they were stored (never recomputed)
14 version: number;
15 // The project's folder name, as the hooks record it; "" when unknown
16 project: string;
17 // The API-price cost, null when the file does not carry it
18 usd: number | null;
19 // The session's dominant model as the hooks record it, "" when unknown
20 model: string;
21 // Its tokens by kind, null when the file does not carry them (a hook older than
22 // the pane's detail)
23 tokens: TokenSplit | null;
24};
25
26// A session's tokens by kind: uncached input, cache writes, cache reads, output
27export type TokenSplit = {
28 input: number;
29 cacheWrite: number;
30 cacheRead: number;
31 output: number;
32};
33
34// The current methodology: what the running session is measured with
35export const CURRENT_VERSION = 2;
36
37export type Range = "7d" | "30d" | "1y" | "all" | "custom";
38// One stripe per day, week or month: picked from the period's length, so the stripes
39// always cover the whole period.
40export type Step = "day" | "week" | "month";
41// A custom range runs from the start of day `from` to the end of day `to`
42// ("2026-05-01"), local time.
43export type View = { range: Range; from?: string; to?: string };
44
45export const RANGES: readonly Range[] = ["7d", "30d", "1y", "all", "custom"];
46export const DEFAULT_VIEW: View = { range: "30d" };
47export const HISTORY_FILE = "sessions-history.tsv";
48// Below this many past sessions there is no median of one's own: the molecule is coloured
49// against a starting reference instead, 0.5 kgCO₂ a session.
50export const MIN_SESSIONS = 10;
51export const START_MEDIAN_GRAMS = 500;
52// Below this many periods with sessions in them the stripes stay uncoloured.
53export const MIN_STRIPES = 3;
54
55const DAY_MS = 86_400_000;
56const RANGE_DAYS: Record<"7d" | "30d" | "1y", number> = {
57 "7d": 7,
58 "30d": 30,
59 "1y": 365,
60};
61const DAY_KEY = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
62
63export function isView(value: unknown): value is View {
64 const v = value as Partial<View> | null | undefined;
65 if (typeof v !== "object" || v === null || !RANGES.includes(v.range as Range))
66 return false;
67 if (v.range !== "custom") return true;
68 return (
69 typeof v.from === "string" &&
70 DAY_KEY.test(v.from) &&
71 typeof v.to === "string" &&
72 DAY_KEY.test(v.to) &&
73 v.from <= v.to
74 );
75}
76
77// The file's directory, resolved as the bash hooks resolve carbon.db: CLAUDE_CARBON_DB's
78// directory, else CLAUDE_CONFIG_DIR/claude-carbon, else the home directory's
79// .claude/claude-carbon (HOME, or USERPROFILE on Windows, where the native process has
80// no HOME). Null when nothing names a directory.
81export function historyPath(env: {
82 db?: string;
83 configDir?: string;
84 home?: string;
85 userProfile?: string;
86}): string | null {
87 const nonEmpty = (v: string | undefined) =>
88 v !== undefined && v !== "" ? v : undefined;
89 const db = nonEmpty(env.db);
90 if (db !== undefined) {
91 const cut = Math.max(db.lastIndexOf("/"), db.lastIndexOf("\\"));
92 let dir = cut > 0 ? db.slice(0, cut) : cut === 0 ? "" : ".";
93 // A Git Bash spelling (/c/Users/...) as the bash side accepts it, on Windows
94 const msys = /^\/([a-zA-Z])(\/.*)?$/.exec(dir);
95 if (msys !== null && env.userProfile !== undefined)
96 dir = `${(msys[1] ?? "").toUpperCase()}:${msys[2] ?? "/"}`;
97 return `${dir}/${HISTORY_FILE}`;
98 }
99 const config = nonEmpty(env.configDir);
100 if (config !== undefined) return `${config}/claude-carbon/${HISTORY_FILE}`;
101 const home = nonEmpty(env.home) ?? nonEmpty(env.userProfile);
102 if (home !== undefined)
103 return `${home}/.claude/claude-carbon/${HISTORY_FILE}`;
104 return null;
105}
106
107// The flag a marketplace install's SessionStart hook writes once a day (check-update.sh):
108// the newest released version. In the config's claude-carbon folder, where the hooks keep
109// their state, whatever CLAUDE_CARBON_DB says.
110export const UPDATE_FILE = "plugin-update-check.json";
111export function updateFlagPath(env: {
112 configDir?: string;
113 home?: string;
114 userProfile?: string;
115}): string | null {
116 const nonEmpty = (v: string | undefined) =>
117 v !== undefined && v !== "" ? v : undefined;
118 const config = nonEmpty(env.configDir);
119 if (config !== undefined) return `${config}/claude-carbon/${UPDATE_FILE}`;
120 const home = nonEmpty(env.home) ?? nonEmpty(env.userProfile);
121 if (home !== undefined) return `${home}/.claude/claude-carbon/${UPDATE_FILE}`;
122 return null;
123}
124
125const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
126function isNewer(a: string, b: string): boolean {
127 const x = SEMVER.exec(a);
128 const y = SEMVER.exec(b);
129 if (x === null || y === null) return false;
130 for (let i = 1; i <= 3; i++) {
131 const d = Number(x[i]) - Number(y[i]);
132 if (d !== 0) return d > 0;
133 }
134 return false;
135}
136
137// The released version this install lacks, or null. Compared with the running plugin's
138// own version, not the flag's, so an update applied since the last check says nothing;
139// a flag older than a week is ignored, as the status line ignores its own.
140export function newerRelease(
141 flagText: string,
142 ownVersion: string | null,
143 nowMs: number,
144): string | null {
145 if (ownVersion === null) return null;
146 let flag: unknown;
147 try {
148 flag = JSON.parse(flagText);
149 } catch {
150 return null;
151 }
152 if (typeof flag !== "object" || flag === null) return null;
153 const { remote, checked_at } = flag as { remote?: unknown; checked_at?: unknown };
154 if (typeof remote !== "string" || typeof checked_at !== "number") return null;
155 if (nowMs - checked_at * 1000 > 7 * 86400 * 1000) return null;
156 return isNewer(remote, ownVersion) ? remote : null;
157}
158
159// One line per session: id, start in epoch seconds, grams, methodology version, project,
160// API-price cost, model, then the tokens by kind (uncached input, cache write, cache
161// read, output), tab-separated. Fields an older hook did not write read as the current
162// version and as unknown.
163// A line that does not read as that is skipped, never guessed.
164export function parseHistory(text: string): SessionRow[] {
165 const rows: SessionRow[] = [];
166 for (const line of text.split(/\r?\n/)) {
167 const [id, start, grams, version, project, cost, model, ...counts] =
168 line.split("\t");
169 if (
170 id === undefined ||
171 id === "" ||
172 start === undefined ||
173 grams === undefined
174 )
175 continue;
176 const seconds = Number(start);
177 const g = Number(grams);
178 if (!Number.isFinite(seconds) || !Number.isFinite(g) || !(g > 0)) continue;
179 const v = version === undefined || version === "" ? CURRENT_VERSION : Number(version);
180 rows.push({
181 id,
182 at: seconds * 1000,
183 grams: g,
184 version: Number.isFinite(v) ? v : CURRENT_VERSION,
185 project: project ?? "",
186 usd: numberOrNull(cost),
187 model: model ?? "",
188 tokens: tokenSplitOf(counts),
189 });
190 }
191 return rows.sort((a, b) => a.at - b.at);
192}
193
194function tokenSplitOf(fields: string[]): TokenSplit | null {
195 if (fields.length < 4) return null;
196 const n = fields.slice(0, 4).map((f) => (f === "" ? NaN : Number(f)));
197 if (!n.every((v) => Number.isFinite(v) && v >= 0)) return null;
198 return { input: n[0] ?? 0, cacheWrite: n[1] ?? 0, cacheRead: n[2] ?? 0, output: n[3] ?? 0 };
199}
200
201function numberOrNull(field: string | undefined): number | null {
202 return field === undefined || field === "" || !Number.isFinite(Number(field))
203 ? null
204 : Number(field);
205}
206
207// The past sessions: the one running now is in the file with a partial total, and the
208// pane shows it from its live total instead.
209export function pastSessions(
210 rows: readonly SessionRow[],
211 currentId: string | null,
212) {
213 return currentId === null
214 ? [...rows]
215 : rows.filter((r) => r.id !== currentId);
216}
217
218// The median the molecule's colour is measured against: all past sessions, as the
219// reports and the published figures count them, or null with too few of them.
220export function sessionMedian(past: readonly SessionRow[]): number | null {
221 if (past.length < MIN_SESSIONS) return null;
222 const m = median(past.map((r) => r.grams));
223 return m > 0 ? m : null;
224}
225
226// What the colours are measured against: the person's own median once there is one, the
227// starting reference before, so the molecule has a colour from the first answer.
228export function colourMedian(past: readonly SessionRow[]): number {
229 return sessionMedian(past) ?? START_MEDIAN_GRAMS;
230}
231
232// Where /carbon-panel shows: the band above a terminal's prompt when it was typed there,
233// the side pane otherwise. A session run in a terminal and driven from the desktop app
234// or a phone (Remote Control, origin "bridge") opens the pane where it was asked for.
235export function panelSite(
236 surfaces: readonly string[],
237 originKind: string,
238): "band" | "pane" {
239 return surfaces.includes("terminal") && originKind !== "bridge" ? "band" : "pane";
240}
241
242// A folder's name from a path, as the hooks' basename gives it
243export function folderName(path: string): string {
244 const parts = path.split(/[\\/]/).filter((p) => p !== "");
245 return parts.at(-1) ?? "";
246}
247
248export type ProjectTotal = { project: string; grams: number; share: number };
249export type PeriodTotal = {
250 grams: number;
251 sessions: number;
252 // The API-price cost of the sessions that carry one, null when none does
253 usd: number | null;
254 // Heaviest first
255 projects: ProjectTotal[];
256};
257
258export type Bar = {
259 // Start of the period, ms since the epoch, local time
260 start: number;
261 grams: number;
262 sessions: number;
263};
264
265// Days, weeks and months are built from the date's parts, never by moving an hour:
266// where midnight is skipped for summer time, the period still starts at its first
267// instant.
268function dayStart(ms: number): number {
269 const d = new Date(ms);
270 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
271}
272
273// Monday 00:00, local time
274function weekStart(ms: number): number {
275 const d = new Date(ms);
276 const offset = (d.getDay() + 6) % 7;
277 return new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset).getTime();
278}
279
280function monthStart(ms: number): number {
281 const d = new Date(ms);
282 return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
283}
284
285function startOf(step: Step, ms: number): number {
286 return step === "day" ? dayStart(ms) : step === "week" ? weekStart(ms) : monthStart(ms);
287}
288
289function nextPeriod(start: number, step: Step): number {
290 const d = new Date(start);
291 return step === "day"
292 ? new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
293 : step === "week"
294 ? new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7).getTime()
295 : new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
296}
297
298// "2026-05-01" for a time, local
299export function dayKey(ms: number): string {
300 const d = new Date(ms);
301 const pad = (n: number) => String(n).padStart(2, "0");
302 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
303}
304
305function dayOf(key: string): number {
306 const [y, m, d] = key.split("-").map(Number);
307 return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1).getTime();
308}
309
310// A date the person typed, "01/05/2026" (day first) or "2026-05-01", as a day key; null
311// when it does not read as a real date.
312export function parseDay(text: string): string | null {
313 const s = text.trim();
314 const dmy = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/.exec(s);
315 const ymd = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s);
316 const [y, m, d] = dmy
317 ? [Number(dmy[3]), Number(dmy[2]), Number(dmy[1])]
318 : ymd
319 ? [Number(ymd[1]), Number(ymd[2]), Number(ymd[3])]
320 : [NaN, NaN, NaN];
321 if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
322 const date = new Date(y, m - 1, d);
323 if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d)
324 return null;
325 return dayKey(date.getTime());
326}
327
328export type Window = { start: number; end: number; step: Step };
329
330// The period a view covers and its stripe: a day for up to 45 days, a week for up to
331// 400, a month beyond. The presets end now; the whole history starts at the first
332// session's period; a custom range covers its days whole, up to now, from its first day
333// exactly (its first stripe, a week or a month, is then partial).
334export function windowOf(
335 view: View,
336 rows: readonly SessionRow[],
337 now: number,
338): Window {
339 const stepFor = (span: number): Step =>
340 span <= 45 * DAY_MS ? "day" : span <= 400 * DAY_MS ? "week" : "month";
341 if (view.range === "custom" && view.from !== undefined && view.to !== undefined) {
342 const from = dayOf(view.from);
343 const end = Math.min(now, nextPeriod(dayOf(view.to), "day") - 1);
344 return { start: from, end, step: stepFor(end - from) };
345 }
346 if (view.range === "all" || view.range === "custom") {
347 const first = rows[0]?.at ?? now;
348 const step = stepFor(now - first);
349 return { start: startOf(step, first), end: now, step };
350 }
351 const days = RANGE_DAYS[view.range];
352 const step: Step = days <= 45 ? "day" : "week";
353 // The current day or week is the last of `days` days, as a calendar reads it. A
354 // history younger than the range starts the stripes at its first session: before it
355 // there is no data, not a pause.
356 const first = rows[0]?.at;
357 // Back by calendar days, not by 24-hour spans: a change of clock in between moves
358 // neither the first day nor the count of stripes.
359 const today = new Date(now);
360 const from = startOf(
361 step,
362 new Date(today.getFullYear(), today.getMonth(), today.getDate() - (days - 1)).getTime(),
363 );
364 return {
365 start: first === undefined ? from : Math.max(from, startOf(step, first)),
366 end: now,
367 step,
368 };
369}
370
371function sumOf(rows: readonly SessionRow[]): PeriodTotal {
372 const grams = rows.reduce((s, r) => s + r.grams, 0);
373 const priced = rows.filter((r) => r.usd !== null);
374 const usd = priced.length > 0 ? priced.reduce((s, r) => s + (r.usd ?? 0), 0) : null;
375 const byProject = new Map<string, number>();
376 for (const r of rows)
377 byProject.set(r.project, (byProject.get(r.project) ?? 0) + r.grams);
378 const projects = [...byProject.entries()]
379 .map(([project, g]) => ({ project, grams: g, share: grams > 0 ? g / grams : 0 }))
380 .sort((a, b) => b.grams - a.grams);
381 return { grams, sessions: rows.length, usd, projects };
382}
383
384
385// What the stripes cover, summed, as the reports sum it.
386export function periodTotal(rows: readonly SessionRow[], w: Window): PeriodTotal {
387 return sumOf(rows.filter((r) => r.at >= w.start && r.at <= w.end));
388}
389
390// The same length of time just before, for the comparison; null for the whole history.
391export function previousTotal(
392 rows: readonly SessionRow[],
393 w: Window,
394 view: View,
395): PeriodTotal | null {
396 if (view.range === "all") return null;
397 const before = w.start - (w.end - w.start);
398 return sumOf(rows.filter((r) => r.at >= before && r.at < w.start));
399}
400
401// One stripe per period of the window, empty ones included (0 g): a quiet day or week
402// shows as a gap, and the stripes always run from the window's start to its end.
403export function bars(rows: readonly SessionRow[], w: Window): Bar[] {
404 const totals = new Map<number, Bar>();
405 for (const r of rows) {
406 if (r.at < w.start || r.at > w.end) continue;
407 const start = startOf(w.step, r.at);
408 const bar = totals.get(start) ?? { start, grams: 0, sessions: 0 };
409 bar.grams += r.grams;
410 bar.sessions += 1;
411 totals.set(start, bar);
412 }
413 const out: Bar[] = [];
414 const last = startOf(w.step, w.end);
415 for (let s = startOf(w.step, w.start); s <= last; s = nextPeriod(s, w.step))
416 out.push(totals.get(s) ?? { start: s, grams: 0, sessions: 0 });
417 return out;
418}
419
420// The median the stripes' colours are measured against: over the periods that have
421// sessions, or null with too few of them.
422export function barMedian(list: readonly Bar[]): number | null {
423 const full = list.filter((b) => b.grams > 0).map((b) => b.grams);
424 if (full.length < MIN_STRIPES) return null;
425 const m = median(full);
426 return m > 0 ? m : null;
427}
428hooks/mod/lib/equiv.ts 64 lines1// The car equivalence of the /carbon-panel pane: the same set, factor, label and source
2// as /carbon-report and the cards (data/factors.json "equivalences", set picked as
3// scripts/equiv-lib.sh detect_equiv_set picks it). Pure: no Claude Code API.
4
5export type CarEquivalence = {
6 // gCO2e per unit driven
7 divisor: number;
8 // "km" or "miles"
9 unit: string;
10 // "km en voiture", "miles driven by car"
11 label: string;
12 // "142 g/km, ADEME 2025"
13 tag: string;
14};
15
16// The first locale variable that speaks (C and POSIX are sentinels), as the bash
17// does: France's set for fr / fr_FR, the US set for a US locale, the world set
18// otherwise. None set (the desktop app): French when the language is French, else
19// the world set.
20export function equivalenceSet(
21 envValues: ReadonlyArray<string | undefined>,
22 lang: "fr" | "en",
23): "fr" | "us" | "world" {
24 const value = envValues.find(
25 (v) =>
26 v !== undefined &&
27 v !== "" &&
28 v !== "C" &&
29 !v.startsWith("C.") &&
30 v !== "POSIX" &&
31 !v.startsWith("POSIX."),
32 );
33 if (value === undefined) return lang === "fr" ? "fr" : "world";
34 if (/^(fr|fr\..*|fr_FR.*|fr-FR.*)$/.test(value)) return "fr";
35 if (/^(us|US|.*_US.*|.*-US.*)$/.test(value)) return "us";
36 return "world";
37}
38
39// The set's car row, or null when the file does not hold one that reads.
40export function carEquivalence(
41 factorsJson: unknown,
42 set: string,
43): CarEquivalence | null {
44 const sets = (factorsJson as { equivalences?: Record<string, unknown> })
45 ?.equivalences;
46 const rows = sets?.[set];
47 if (!Array.isArray(rows)) return null;
48 const car = rows.find(
49 (r): r is Record<string, unknown> =>
50 typeof r === "object" && r !== null && (r as { id?: unknown }).id === "car",
51 );
52 if (car === undefined) return null;
53 const { divisor, unit, label, tag } = car;
54 if (
55 typeof divisor !== "number" ||
56 !(divisor > 0) ||
57 typeof unit !== "string" ||
58 typeof label !== "string" ||
59 typeof tag !== "string"
60 )
61 return null;
62 return { divisor, unit, label, tag };
63}
64types/index.d.ts 62 lines1// The values the claude-carbon mod keeps in $.state for the session.
2
3export type AnswerCo2 = {
4 main: string;
5 sub: string | null;
6 isUnusual: boolean;
7};
8
9// One main-loop turn, written when it ends (`at`, ms since the epoch). Its duration
10// line takes it when first drawn within a second of that (hooks/mod/lib/lines.ts).
11// A turn that counted nothing (an excluded model, no usage) keeps a null co2.
12export type AnswerCo2Entry = {
13 turnId: string;
14 durationMs: number;
15 at: number;
16 co2: AnswerCo2 | null;
17};
18
19// usd: the API-price cost (data/prices.json) of the same requests as grams. Absent once
20// one of them could not be priced (no price list read, or a total kept from code that
21// counted no cost): a part of the cost is never shown as the whole.
22export type SessionTotal = {
23 grams: number;
24 usd?: number;
25 turns: number;
26 // The session's tokens by kind, for the pane's detail; absent in a total kept from
27 // code that did not count them
28 tokens?: { input: number; cacheWrite: number; cacheRead: number; output: number };
29};
30
31// What the /carbon-panel pane covers: a preset range, or months picked by the person
32// ("2026-05"); one stripe per day, week or month follows from its length.
33export type PaneView = {
34 range: "7d" | "30d" | "1y" | "all" | "custom";
35 from?: string;
36 to?: string;
37};
38
39// The blocks the /carbon-panel pane shows under the stripes, besides the total
40export type PaneBlocks = {
41 compare: boolean;
42 cost: boolean;
43 equivalence: boolean;
44 projects: boolean;
45 detail: boolean;
46};
47
48declare module "claude-code" {
49 interface PluginState {
50 "claude-carbon": {
51 answers: AnswerCo2Entry[];
52 session: SessionTotal;
53 // True from a main-loop turn's start to its end: the molecule walks.
54 working: boolean;
55 // True while /carbon-panel shows the molecule in the band above a terminal's prompt
56 band: boolean;
57 view: PaneView;
58 blocks: PaneBlocks;
59 };
60 }
61}
62