SLOPSHOPPER

claude-carbon

Track the carbon footprint of your Claude Code sessions

newpanebandspinnerguardcommand
★ 213v1.15.5MITupdated 2026-10-16gwittebolle/claude-carbon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-carbon
│ ┃ carbon ✕ › fix the failing auth test and ad╭────────────────────────────────╮ │ ┃ This session: waiting for the │ claude-carbon │ │ ┃ ▄▄ ▀▀▀▀▀ ▄▄ 1 answer · 0 subagent ⏺ Read(src/auth.ts) │ CO₂ now shows for each answer. │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀ Context 49 % ⎿ Read 6 lines ╰────────────────────────────────╯ │ ┃ ▀▀▀▀▀ The history of your sessions ⏺ Update(src/auth.ts) │ ┃ the full install (see the REA ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /carbon-panel │ │ This session: waiting for the first answer ▄▄ ▀▀▀▀▀ ▄▄ 1 answer · 0 subagent ▀▀▀▀▀▀▀▀▀▀▀▀▀ Context 49 % ▀▀▀▀▀ The history of your sessions comes with the full install (see the README). ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
This session: waiting for the first answer ▄▄ ▀▀▀▀▀ ▄▄ 1 answer · 0 subagent ▀▀▀▀▀▀▀▀▀▀▀▀▀ Context 49 % ▀▀▀▀▀ The history of your sessions comes with the full install (see the README). ⟨Claude Code's own drawing⟩
Pane · carbon
This session: waiting for the first answer ▄▄ ▀▀▀▀▀ ▄▄ 1 answer · 0 subagent ▀▀▀▀▀▀▀▀▀▀▀▀▀ Context 49 % ▀▀▀▀▀ The history of your sessions comes with the f (see the README).
README

GitHub stars License: MIT GitHub release npm CI

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.

What claude-carbon does

  • /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
  • Adds a live CO2 estimate to the Claude Code status line, and the session's CO2 under the prompt in the Claude desktop app, each next to the session's API-equivalent cost
  • Persists each session to a local SQLite database, so history survives the 30-day transcript purge
  • Backfills historical data from existing ~/.claude transcripts
  • Slash commands: /carbon-report (text), /carbon-card (PNG), /carbon-pr (footprint of a pull request)
  • A README badge with your all-time total, and a public methodology with every factor sourced

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.

Install

Two ways, same result. Pick the one that fits you:

Already installed? See Updating.

Option 1: from the Claude desktop app (no terminal)

Illustration of the Claude desktop app with the claude-carbon pane open: a blue pixel molecule, "This session: 8.2 gCO₂ · 0.04× your median", the last 30 days as tiles and warming stripes, a "Get my 2026 card" button, and "This session: 8.2 gCO₂" under the prompt

  1. Open the Code tab of the Claude desktop app and start a session, in any folder.
  2. Paste the message for your system. It works in any language. Claude runs the installer for you and asks before running the command: accept.

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
  1. Quit the desktop app completely, open it again, and start a new session in the Code tab. Under the prompt you now see 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

  • macOS opens a window about "command line developer tools". That is 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."
  • "jq is not installed" or "sqlite3 is not installed". Tell Claude: "Install what is missing, then run the claude-carbon install again." Every Mac has 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.
  • Windows says Git Bash is missing. Install Git for Windows (Claude Code needs it on Windows anyway), reopen the app, and ask Claude again.

The chat and Cowork tabs are not covered: their sessions run on Anthropic's servers, so nothing reaches your machine to count.

Option 2: from a terminal (developers)

Live CO2 in the Claude Code status line

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-community entry 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 claude in 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-report covers them. If you use the VS Code panel and want the live CO2, open a terminal in VS Code (` Ctrl+ `) and run claude` 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:

  1. stdin (preferred): if Claude Code injects rate_limits.five_hour.used_percentage in the statusline JSON, that value is used straight away.
  2. OAuth API fallback: 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)

Example report

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

For teams

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:

  • Free calculator and per-model factor sheets - the exact versioned factors of this repo, browsable.
  • AI usage report - a self-serve, shareable report of your organisation's real Claude usage (cost, CO2e, water, energy), generated in minutes from an Anthropic admin key. The key is never stored; the methodology annex is citation-ready.
  • TokenClimate - hosted team dashboards. Both sides share this repo's golden vectors, verified weekly in CI.

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.

PR dev footprint (/carbon-pr)

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.

How it works

claude-carbon data flow: Claude Code transcripts and status line JSON to a local SQLite database and a CO2 estimate

Three data paths, two levels of accuracy:

ScriptTriggerData sourceSubagentsCache readsAccuracy
backfill.shManual / setupJSONL filesIncludedCounted (8% energy)Best estimate
persist-session.shStop + SessionEnd hooksJSONL filesIncludedCounted (8% energy)Best estimate
statusline.shEvery turn (live)carbon.db rowIncludedCounted (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.

Surviving the 30-day transcript purge

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").

Commands

CommandWhat it does
/carbon-reportText report with totals, car equivalent, share of a 2 t budget, top sessions
/carbon-cardGenerate shareable PNG report cards
/carbon-badgeShields.io badge of your total footprint for your READMEs
/carbon-prPost the dev footprint of the current branch on its PR
/carbon-updateUpdate to the latest version and re-price history
ScriptWhat it does
setup.shInit database, backfill historical sessions, show total
statusline.shStatus line script (called automatically by Claude Code)
persist-session.shStop hook (saves session data after each turn)
persist-on-exit.shSessionEnd hook (saves the session once more as it closes, in the background)
safety-rescan.shSessionStart hook (throttled background re-scan, catches missed sessions)
backfill.shRe-parse all historical JSONL transcripts (incl. subagents)
recompute.shRe-derive cost/CO2 from stored tokens after a price/factor change (no transcripts needed)
generate-report.shExport PNG report cards (CLI, with --since / --until / --all)
generate-badge.shPrint the shields.io badge markdown + URL (CLI)
generate-pr-report.shPost 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.

Badge

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

Claude Code carbon footprint

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

[![Claude Code carbon footprint](https://img.shields.io/badge/claude--carbon-1139.2%20kg%20CO2e%2C%202026--09-2f6f4f)](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 mod: /carbon-panel and the CO2 of each answer

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.

  • Terminal, including the integrated terminal of an editor and the JetBrains plugin: at the right end of the line that closes each answer, ✻ 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.
  • Desktop app, Code tab: a line under the answer, only for an unusual answer.
  • An unusual answer shows the ratio, and the subagents' share when they we
Source 10 files
hooks/mod/register.tsx 1575 lines
1// 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 lines
1// 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}
109
hooks/mod/lib/cost.ts 174 lines
1// 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}
174
hooks/mod/lib/detail.ts 191 lines
1// 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}
191
hooks/mod/lib/format.ts 789 lines
1// 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}
789
hooks/mod/lib/lines.ts 52 lines
1// 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}
52
hooks/mod/lib/mascot.ts 620 lines
1// 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("&", "&amp;")
322    .replaceAll("<", "&lt;")
323    .replaceAll(">", "&gt;")
324    .replaceAll('"', "&quot;");
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}
620
hooks/mod/lib/sessions.ts 428 lines
1// 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}
428
hooks/mod/lib/equiv.ts 64 lines
1// 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}
64
types/index.d.ts 62 lines
1// 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