windows: import the Tauri tray app as the Windows menubar

Brings the Tauri 2.x tray popover from #1022 onto main as windows/, mirroring
mac/. Product name, bundle identifier, and version line up with the macOS
menubar (org.agentseal.codeburn-menubar, 0.9.20); the crate is renamed off
"desktop" so it no longer collides with the Electron app in app/.

Linux (ksni) stays compiled and dev-usable but is documented as experimental:
gnome/ is the shipping Linux surface.

The five src/ CLI commits on that branch are dropped - they re-implement a
daily-bucketing fix main already carries.
This commit is contained in:
iamtoruk 2026-08-18 04:00:59 -07:00
parent a0ada25104
commit b199af182e
68 changed files with 14791 additions and 0 deletions

10
windows/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
node_modules/
# dist/ is a Vite build output; we gitignore its contents but keep a tracked placeholder
# index.html so Tauri's compile-time `frontendDist` validation passes on fresh clones before
# anyone has run `npm run build`. Real build output replaces the placeholder locally.
dist/*
!dist/index.html
src-tauri/target/
src-tauri/gen/
.DS_Store
*.log

169
windows/DEVELOPMENT.md Normal file
View file

@ -0,0 +1,169 @@
# CodeBurn Menubar (Windows)
Tauri 2.x tray app that surfaces CodeBurn in the Windows notification area. It is the Windows
mirror of the native macOS menubar in `../mac/`, which stays the authoritative look and feel;
this project mirrors its layout, colors, and data via the shared `tokens.json`.
Linux (ksni / AppIndicator) support is compiled and kept working for dev, but it is
**experimental and unreleased** - Linux users should use the GNOME extension in `../gnome/`.
The releases this repo cuts from here are Windows only.
## Architecture
```
windows/
├── src/ React + TypeScript popover UI (runs inside the Tauri webview)
├── src-tauri/
│ ├── src/
│ │ ├── main.rs binary entry
│ │ ├── lib.rs tray, window lifecycle, state wiring
│ │ ├── cli.rs argv-validated spawn of the codeburn CLI
│ │ ├── config.rs ~/.config/codeburn/config.json read/write under a lock
│ │ ├── plan.rs Claude OAuth quota (port of mac/.../ClaudeSubscriptionService.swift)
│ │ └── fx.rs Frankfurter fetch + 24h disk cache + [0.0001, 1e6] clamp
│ ├── capabilities/ Tauri v2 permission manifests
│ └── icons/ tray + bundle icons
└── tokens.json shared design tokens (also consumed by mac/ at build time)
```
## Prerequisites (Windows)
```powershell
# Rust
winget install Rustlang.Rustup
rustup target add x86_64-pc-windows-msvc
# WebView2 Runtime
winget install Microsoft.EdgeWebView2Runtime
# Microsoft C++ Build Tools (ships with Visual Studio Installer; pick "Desktop development with C++")
```
## Prerequisites (macOS / Linux, dev only)
Tauri builds on macOS and Linux for inner-loop UI iteration. The shipping macOS product is the
Swift app in `../mac/`, so we don't cut a Tauri Mac release.
```bash
# macOS
brew install rust node
# Ubuntu / Debian
sudo apt update
sudo apt install -y \
build-essential curl wget file \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libxdo-dev \
libgtk-3-dev
```
## Run the dev server
```bash
cd windows
npm install
npm run tauri dev
```
Under the hood this starts Vite on `localhost:1420`, builds `src-tauri/target/debug/codeburn-menubar`,
and opens a window wired to the dev server with hot reload for the React code. The tray icon
appears at the same time.
If the codeburn CLI isn't on PATH (dev builds from this monorepo), point the app at your local build:
```bash
npm --prefix .. run build
CODEBURN_BIN="node $(pwd)/../dist/cli.js" npm run tauri dev
```
`CODEBURN_BIN` is validated against a strict allowlist (alphanumerics plus `._/-` and space;
`\ : ( )` also allowed on Windows) before use; anything else falls back to auto-resolution.
Without `CODEBURN_BIN` the app looks for `codeburn` (`codeburn.cmd` / `codeburn.exe` on Windows)
on the inherited `PATH`, then in the usual npm and node prefixes (`%APPDATA%\npm`,
`%LOCALAPPDATA%\Programs\nodejs`, pnpm, Volta, scoop, `/opt/homebrew/bin`, `~/.npm-global/bin`),
and finally on Windows in the live user and machine `PATH` read from the registry, so a CLI
installed after the tray app was launched is still found. Only absolute directory entries are
considered - empty or relative `PATH` entries are skipped so nothing is ever resolved out of the
current working directory.
If nothing is found, or `codeburn --version` is older than `MIN_CLI_VERSION`
(`src-tauri/src/cli.rs`), the popover shows a setup screen with the install command and a
"Check again" button. That gate is probed once on mount, before the first payload fetch.
`MIN_CLI_VERSION` is **0.9.9**: the first release whose `codeburn status --format menubar-json`
accepts `--no-optimize`, which the app's quiet background refreshes always pass. Every payload
field the popover reads (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`)
also exists at that version.
## Refresh policy
Mirrors `mac/Sources/CodeBurnMenubar/RefreshCadence.swift`: each CLI fetch is a full Node
process, so the cadence follows popover visibility.
- popover visible: 60 s tick, full fetch (optimize findings included)
- popover hidden: 120 s tick, `today`/`all` only, `--no-optimize`
- on show: immediate refresh when the visible key is older than 60 s
## Plan / quota
The Plan pill (visible on the Claude tab, or when Claude is the only detected provider) reads
Claude Code's OAuth credentials from `~/.claude/.credentials.json`, calls
`https://api.anthropic.com/api/oauth/usage`, refreshes the token once on 401, and stores one
snapshot per window under `~/.cache/codeburn/subscription-snapshots.json` (`CODEBURN_CACHE_DIR`
override) so a freshly reset window can still show last cycle's final. This is the same file
format the macOS app writes. Nothing is logged: the credential blob never leaves the Rust side.
## Build a production package
```bash
# Windows (.msi + NSIS .exe): run from a Windows host
npm run tauri build
# Linux (experimental): produces .deb, .rpm, .AppImage under src-tauri/target/release/bundle/
npm run tauri build
```
## Security model
- **Process spawn**: every call into the codeburn CLI goes through `CodeburnCli::fetch_menubar_payload`,
which builds argv explicitly and runs the binary directly (no `sh -c`). `CODEBURN_BIN` is
allowlisted before use. Windows system tools (`reg.exe`, `cmd.exe`) are invoked by absolute
path under `%SystemRoot%\System32` so `CreateProcess`'s current-directory search can never
pick up a planted binary; `claude` is resolved from absolute `PATH` directories the same way.
- **Pipes**: stdout is capped at 20 MB, stderr at 256 KB, total wall time at 60 s. A hung CLI
cannot pin file descriptors or memory.
- **Config writes**: `~/.config/codeburn/config.json` writes run under a POSIX `flock` on
`~/.config/codeburn/.config.lock`. On Windows the same path uses a create-new lock file. Note
that this lock is advisory *between instances of this app only* - the codeburn CLI does not
take it - so it narrows, but does not eliminate, a concurrent-write race. A lock left behind
by a crash is never deleted by another process; it expires after 30 s of inactivity and the
next writer retries.
- **Snapshot writes**: `subscription-snapshots.json` refuses a symlinked target and is written
0600 on unix, mirroring `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`.
- **Credentials**: the Plan view reads `~/.claude/.credentials.json` with a 64 KB cap and refuses
symlinks; tokens are only ever sent to the Anthropic usage and token endpoints over TLS.
- **FX fetches**: Frankfurter response is parsed as JSON and the rate is clamped to
`[0.0001, 1_000_000]` before it touches displayed numbers. Stale cache preferred over poisoned
fresh data.
- **CSP**: `connect-src` restricted to `self`, `ipc:`, and `https://api.frankfurter.app`. No
inline scripts.
## CI and release tags
- `.github/workflows/windows-menubar-ci.yml` runs on any `windows/**` change: `tsc --noEmit`,
`cargo clippy -D warnings` and `cargo test` on windows-latest + ubuntu-latest, plus a release
build smoke on Windows.
- `windows-v*` tag (e.g. `windows-v0.9.20`) triggers
`.github/workflows/release-menubar-windows.yml`; publishes the `.msi` and NSIS `.exe` to a
"Windows Menubar vX" release. Unsigned for now, so Windows SmartScreen prompts on first run
until a signing cert is in place.
## Pending work
1. Code signing for the Windows `.msi` to remove the SmartScreen warning.
2. Linux: decide whether to ship at all (the GNOME extension in `../gnome/` covers that
surface today) or promote the ksni tray out of experimental.

View file

@ -0,0 +1,38 @@
# Unattended Ubuntu install for the CodeBurn dev VM
This directory contains a cloud-init `user-data` + `meta-data` pair that tells the Ubuntu 24.04 Server installer to configure itself without any user prompts. After it finishes, you reboot into GNOME and run the one-line provisioner.
Default credentials in `user-data`: **`codeburn` / `codeburn`**. Change them before using anywhere that matters.
## Build the CIDATA ISO (on your Mac)
```bash
cd windows/Scripts/autoinstall
hdiutil makehybrid -o codeburn-cidata.iso \
-hfs -joliet -iso -default-volume-name CIDATA .
```
That produces `codeburn-cidata.iso` (around 2 KB) with the two YAML files at the root, labelled `CIDATA`.
## Hook it into UTM
1. Create the VM as usual (Virtualize → Linux → Ubuntu Server arm64 ISO).
2. Before first boot, open the VM's Settings → **Drives****New Drive** → pick **Removable****Import**, and select `codeburn-cidata.iso`.
3. Boot. The Ubuntu installer auto-detects the CIDATA volume, reads the autoinstall config, and runs the install without prompts. Takes 15-20 minutes depending on disk speed.
4. Reboot into the installed system, log in as `codeburn`, then:
```bash
bash ~/provision.sh
```
(The autoinstall drops the script to `~/provision.sh`. It installs Rust + Node + the codeburn CLI, clones the repo, and sets up the windows/ npm deps.)
5. `cd ~/codeburn/windows && npm run tauri dev`.
## Why not automate the provisioner run too
cloud-init's `late-commands` runs in the installer environment, which doesn't have a GNOME session for the tray icon to land in. We deliberately stop short of running `npm run tauri dev` from within autoinstall so the tray shows up on your first real login instead of a detached systemd unit.
## Skipping autoinstall
If you'd rather click through the Ubuntu installer normally, ignore this directory entirely. The `provision-linux.sh` script in the parent directory works the same way whether the OS was installed unattended or by hand.

View file

@ -0,0 +1,2 @@
instance-id: codeburn-linux-01
local-hostname: codeburn-linux

View file

@ -0,0 +1,57 @@
#cloud-config
# Ubuntu 24.04 LTS Server autoinstall configuration for a CodeBurn desktop dev VM. Mount this
# file as a second virtual disk (CIDATA volume) alongside the Ubuntu Server ISO in UTM and
# the installer runs unattended end to end. Default login: codeburn / codeburn. Change the
# identity block before running in any environment that matters.
autoinstall:
version: 1
# Accept the EULA-style prompts without user input.
refresh-installer:
update: yes
locale: en_US.UTF-8
keyboard:
layout: us
# Wire up a default user. Password is `codeburn`; hash generated with `openssl passwd -6`.
# Regenerate the hash if you care about the credentials outside of a throwaway VM.
identity:
hostname: codeburn-linux
username: codeburn
password: "$6$rounds=4096$JrKVZcJ2$F93p8IWyTlZR5p1Trmno/qCnhYI1BnbUUYdf6HsiD.XW4T0I3JtvzH40nWNy9Z1CcJ2X5C6RuzK0bj9WM3x/n."
ssh:
install-server: yes
allow-pw: yes
# Install GNOME + the build dependencies Tauri needs so the first login is already ready
# to run `npm run tauri dev` without another apt round trip.
packages:
- ubuntu-desktop-minimal
- build-essential
- curl
- wget
- file
- git
- libwebkit2gtk-4.1-dev
- libayatana-appindicator3-dev
- librsvg2-dev
- libssl-dev
- libxdo-dev
- libgtk-3-dev
- pkg-config
# Run the provisioner as the new user on first boot. It installs Node + Rust, pulls the
# repo, and runs `npm install` for the desktop app. After the script finishes, logging in
# to GNOME and running `cd ~/codeburn/windows && npm run tauri dev` brings the tray up.
late-commands:
- curtin in-target --target=/target -- bash -lc '
sudo -iu codeburn bash -lc "
curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh \
-o /home/codeburn/provision.sh
chmod +x /home/codeburn/provision.sh
"
'
shutdown: reboot

View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# One-shot Ubuntu provisioning for the CodeBurn desktop (Tauri) dev environment.
#
# Usage inside a fresh Ubuntu 24.04 LTS Server VM (after `sudo apt install
# ubuntu-desktop-minimal && sudo reboot`, and logging into GNOME):
#
# curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh | bash
#
# Or if you cloned the repo manually: `bash windows/Scripts/provision-linux.sh`.
#
# Installs: build toolchain, webkit + appindicator headers, Node 20 LTS, Rust stable,
# the codeburn npm CLI, and this repo. Leaves you one command away from `npm run tauri dev`.
set -euo pipefail
REPO_URL="https://github.com/getagentseal/codeburn.git"
BRANCH="feat/tauri-menubar-win-linux"
CHECKOUT="${HOME}/codeburn"
log() { printf '\033[1;34m▸\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
# 1. Platform sanity
[[ "$(uname -s)" == "Linux" ]] || fail "Run me on Linux (detected: $(uname -s))."
if ! command -v apt-get >/dev/null; then
fail "Only apt-based distros supported by this provisioner (Ubuntu, Debian)."
fi
log "apt update + system build deps"
sudo apt-get update -qq
sudo apt-get install -y \
build-essential curl wget file git \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libxdo-dev \
libgtk-3-dev \
pkg-config
# 2. Node 20 LTS via NodeSource if the distro version is too old. Tauri CLI needs >= 18.
if ! command -v node >/dev/null || [[ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt 18 ]]; then
log "installing Node 20 LTS"
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
fi
# 3. Rust via rustup if not present
if ! command -v cargo >/dev/null; then
log "installing Rust via rustup"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
# shellcheck disable=SC1091
source "$HOME/.cargo/env"
fi
# 4. codeburn CLI (the Tauri app shells out to this for data)
if ! command -v codeburn >/dev/null; then
log "installing codeburn CLI from npm"
sudo npm install -g codeburn
fi
# 5. Repo
if [[ -d "${CHECKOUT}/.git" ]]; then
log "updating existing checkout at ${CHECKOUT}"
git -C "${CHECKOUT}" fetch origin
git -C "${CHECKOUT}" checkout "${BRANCH}"
git -C "${CHECKOUT}" pull --ff-only origin "${BRANCH}"
else
log "cloning ${REPO_URL} into ${CHECKOUT}"
git clone --branch "${BRANCH}" "${REPO_URL}" "${CHECKOUT}"
fi
# 6. npm deps for the desktop app
log "npm install for windows/"
(cd "${CHECKOUT}/windows" && npm install --no-audit --no-fund)
# 7. Summary + next step
cat <<EOF
\033[1;32m✓\033[0m Provisioning complete.
Next:
cd ${CHECKOUT}/windows
npm run tauri dev
A flame tray icon should appear in your panel. Click it for the popover. Hot reload is
wired for the React code; Rust changes need a rebuild.
EOF

4
windows/dist/index.html vendored Normal file
View file

@ -0,0 +1,4 @@
<!doctype html><html><head><meta charset="utf-8"><title>CodeBurn</title></head><body>
<noscript>This app requires JavaScript.</noscript>
<p>Run <code>npm install &amp;&amp; npm run tauri dev</code> from <code>windows/</code>.</p>
</body></html>

12
windows/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeBurn</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2069
windows/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
windows/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "codeburn-menubar",
"private": true,
"version": "0.9.20",
"description": "CodeBurn menubar (tray) app for Windows, with experimental Linux support. Shares design tokens with the native macOS app under mac/.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

6066
windows/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
[package]
name = "codeburn-menubar"
version = "0.9.20"
description = "CodeBurn menubar (tray) app for Windows and Linux"
authors = ["AgentSeal"]
edition = "2021"
rust-version = "1.80"
[lib]
name = "codeburn_menubar_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["process", "io-util", "rt-multi-thread", "macros", "time", "sync"] }
thiserror = "1"
anyhow = "1"
dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
fontdue = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "linux")'.dependencies]
ksni = "0.3"
png = "0.17"
[features]
default = ["custom-protocol"]
# This feature is used for production builds or when a dev server is not specified. Don't
# change it unless you know what you're doing.
custom-protocol = ["tauri/custom-protocol"]

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for the CodeBurn tray app window",
"windows": ["popover"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:tray:default",
"core:event:allow-listen",
"core:event:allow-emit",
"opener:default",
"shell:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,88 @@
//! Launch at login. Windows: a value under HKCU\...\CurrentVersion\Run pointing at this
//! executable. Linux: an XDG autostart .desktop file. No extra crates; both are a few
//! lines of `reg` / plain file IO.
use anyhow::{anyhow, Context, Result};
const APP_NAME: &str = "CodeBurn";
#[cfg(target_os = "windows")]
const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
#[cfg(target_os = "windows")]
fn reg(args: &[&str]) -> Result<std::process::Output> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
std::process::Command::new("reg")
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.with_context(|| "failed to run reg.exe")
}
#[cfg(target_os = "windows")]
pub fn is_enabled() -> bool {
reg(&["query", RUN_KEY, "/v", APP_NAME])
.map(|out| out.status.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn set_enabled(enabled: bool) -> Result<()> {
if enabled {
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
let value = format!("\"{}\"", exe.display());
let out = reg(&["add", RUN_KEY, "/v", APP_NAME, "/t", "REG_SZ", "/d", &value, "/f"])?;
if !out.status.success() {
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
}
} else {
let out = reg(&["delete", RUN_KEY, "/v", APP_NAME, "/f"])?;
// Deleting a value that does not exist is the state we want anyway.
if !out.status.success() && is_enabled() {
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn desktop_file() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|d| d.join("autostart").join("codeburn-menubar.desktop"))
}
#[cfg(target_os = "linux")]
pub fn is_enabled() -> bool {
desktop_file().map(|p| p.is_file()).unwrap_or(false)
}
#[cfg(target_os = "linux")]
pub fn set_enabled(enabled: bool) -> Result<()> {
let path = desktop_file().ok_or_else(|| anyhow!("no config dir"))?;
if enabled {
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(
&path,
format!(
"[Desktop Entry]\nType=Application\nName={APP_NAME}\nExec=\"{}\"\nX-GNOME-Autostart-enabled=true\n",
exe.display()
),
)?;
} else if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_enabled() -> bool {
false
}
#[cfg(target_os = "macos")]
pub fn set_enabled(_enabled: bool) -> Result<()> {
Err(anyhow!("launch at login is handled by the native macOS app"))
}

View file

@ -0,0 +1,499 @@
use std::env;
use std::path::PathBuf;
use std::process::Stdio;
use anyhow::{anyhow, bail, Context, Result};
use serde::Serialize;
use serde_json::Value;
use tauri::AppHandle;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
/// Hard bounds mirror the macOS CodeburnCLI / DataClient design. A malicious or stuck CLI
/// cannot pin the Tauri process: stdout is capped, stderr is bounded, total wall time is
/// 60s. A hostile CODEBURN_BIN is rejected before any shell-resembling path is taken.
const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
const MAX_STDERR_BYTES: usize = 256 * 1024;
const FETCH_TIMEOUT_SECS: u64 = 60;
const VERSION_TIMEOUT_SECS: u64 = 20;
/// Oldest CLI that emits the `menubar-json` shape this app renders (history.daily with
/// per-day model breakdown, providers map). Older CLIs get the setup screen instead of a
/// half-rendered popover.
pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 7, 0);
#[cfg(windows)]
const WINDOWS_CLI_NAMES: [&str; 2] = ["codeburn.cmd", "codeburn.exe"];
/// Alphanumerics plus `._/-` and space, with `\`, `:`, `(`, `)` also allowed on Windows
/// so a user-supplied `CODEBURN_BIN` path like `C:\Users\...\codeburn.cmd` is accepted.
/// None of these are shell metacharacters in a direct-argv spawn (we never invoke `sh -c`).
fn is_safe_arg(value: &str) -> bool {
!value.is_empty()
&& value.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '.' | '_' | '/' | '-' | ' ')
|| (cfg!(windows) && matches!(c, '\\' | ':' | '(' | ')'))
})
}
#[derive(Clone, Debug)]
pub struct CodeburnCli {
program: String,
extra_args: Vec<String>,
}
/// What the setup screen needs to know about the CLI on this machine.
#[derive(Clone, Debug, Serialize)]
pub struct CliStatus {
pub found: bool,
pub program: String,
pub version: Option<String>,
pub min_version: String,
pub compatible: bool,
pub error: Option<String>,
}
impl CodeburnCli {
/// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the
/// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations.
pub fn resolve() -> Self {
let raw = env::var("CODEBURN_BIN").unwrap_or_default();
if raw.is_empty() {
return Self::default_program();
}
// A bare path (which may contain spaces, e.g. under Program Files) is used whole;
// only otherwise is the value split into program + leading arguments.
if is_safe_arg(&raw) && std::path::Path::new(&raw).is_file() {
return CodeburnCli {
program: raw,
extra_args: vec![],
};
}
let parts: Vec<String> = raw.split_whitespace().map(String::from).collect();
if parts.iter().all(|p| is_safe_arg(p)) {
if let Some((first, rest)) = parts.split_first() {
return CodeburnCli {
program: first.clone(),
extra_args: rest.to_vec(),
};
}
}
eprintln!("codeburn-menubar: refusing unsafe CODEBURN_BIN; falling back to `codeburn`");
Self::default_program()
}
fn default_program() -> Self {
CodeburnCli {
program: locate_cli().unwrap_or_else(default_program_name),
extra_args: vec![],
}
}
pub fn program(&self) -> &str {
&self.program
}
/// Runs `codeburn --version` and reports whether the CLI is present and new enough.
pub async fn status(&self) -> CliStatus {
let min_version = format!(
"{}.{}.{}",
MIN_CLI_VERSION.0, MIN_CLI_VERSION.1, MIN_CLI_VERSION.2
);
let mut status = CliStatus {
found: false,
program: self.program.clone(),
version: None,
min_version,
compatible: false,
error: None,
};
match self.run_capture(&["--version"], VERSION_TIMEOUT_SECS).await {
Ok(out) => {
let version = out.trim().to_string();
status.found = true;
status.compatible = parse_version(&version)
.map(|v| v >= MIN_CLI_VERSION)
.unwrap_or(false);
status.version = Some(version);
}
Err(err) => {
status.error = Some(err.to_string());
}
}
status
}
/// Spawns `codeburn status --format menubar-json --period X --provider Y` and decodes the
/// output. Pipes are drained concurrently so a chatty stderr cannot deadlock stdout.
pub async fn fetch_menubar_payload(
&self,
period: &str,
provider: &str,
include_optimize: bool,
) -> Result<Value> {
if !is_safe_arg(period) || !is_safe_arg(provider) {
bail!("invalid period/provider argument");
}
let mut args = vec![
"status",
"--format",
"menubar-json",
"--period",
period,
"--provider",
provider,
];
if !include_optimize {
args.push("--no-optimize");
}
let stdout = self.run_capture(&args, FETCH_TIMEOUT_SECS).await?;
let payload: Value =
serde_json::from_str(&stdout).with_context(|| "CLI returned invalid JSON")?;
Ok(payload)
}
async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result<String> {
let mut full_args = self.extra_args.clone();
full_args.extend(args.iter().map(|s| s.to_string()));
let mut cmd = Command::new(&self.program);
cmd.args(&full_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd.spawn().map_err(|err| {
anyhow!(
"CodeBurn CLI not found ({}). Install it with `npm install -g codeburn`.",
spawn_error_summary(&self.program, &err)
)
})?;
let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let stdout_task = tokio::spawn(async move {
let mut buf = Vec::with_capacity(64 * 1024);
let mut limited = (&mut stdout).take(MAX_PAYLOAD_BYTES as u64);
limited.read_to_end(&mut buf).await.ok();
buf
});
let stderr_task = tokio::spawn(async move {
let mut buf = Vec::with_capacity(4 * 1024);
let mut limited = (&mut stderr).take(MAX_STDERR_BYTES as u64);
limited.read_to_end(&mut buf).await.ok();
buf
});
let status = timeout(Duration::from_secs(timeout_secs), child.wait())
.await
.map_err(|_| anyhow!("codeburn CLI timed out after {}s", timeout_secs))??;
let stdout_bytes = stdout_task.await.unwrap_or_default();
let stderr_bytes = stderr_task.await.unwrap_or_default();
if !status.success() {
let msg = String::from_utf8_lossy(&stderr_bytes);
bail!("codeburn CLI exited {}: {}", status, msg.trim());
}
Ok(String::from_utf8_lossy(&stdout_bytes).into_owned())
}
}
fn spawn_error_summary(program: &str, err: &std::io::Error) -> String {
match err.kind() {
std::io::ErrorKind::NotFound => format!("{} is not on PATH", program),
_ => format!("{}: {}", program, err),
}
}
fn default_program_name() -> String {
#[cfg(windows)]
{
"codeburn.cmd".to_string()
}
#[cfg(not(windows))]
{
"codeburn".to_string()
}
}
/// Parses "0.7.3" or "codeburn 0.7.3" into a comparable tuple.
pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
let token = text
.split_whitespace()
.find(|t| t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false))?;
let mut parts = token.split('.').map(|p| {
p.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.ok()
});
Some((parts.next()??, parts.next()??, parts.next().flatten().unwrap_or(0)))
}
/// Locates the CLI without relying on the inherited PATH being fresh. A tray app is often
/// launched from Explorer or at login, before (or long after) `npm install -g codeburn`
/// changed the user's PATH, so we also read the live PATH from the registry on Windows and
/// probe the standard npm / node install prefixes.
fn locate_cli() -> Option<String> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(path) = env::var_os("PATH") {
dirs.extend(env::split_paths(&path));
}
dirs.extend(extra_search_dirs());
for dir in dirs {
for name in candidate_names() {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate.to_string_lossy().into_owned());
}
}
}
None
}
fn candidate_names() -> Vec<&'static str> {
#[cfg(windows)]
{
WINDOWS_CLI_NAMES.to_vec()
}
#[cfg(not(windows))]
{
vec!["codeburn"]
}
}
#[cfg(windows)]
fn extra_search_dirs() -> Vec<PathBuf> {
let mut out = Vec::new();
for var in ["APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] {
if let Some(base) = env::var_os(var).map(PathBuf::from) {
match var {
"APPDATA" => out.push(base.join("npm")),
"LOCALAPPDATA" => {
out.push(base.join("Programs").join("nodejs"));
out.push(base.join("pnpm"));
out.push(base.join("Volta").join("bin"));
out.push(base.join("fnm_multishells"));
}
_ => out.push(base.join("nodejs")),
}
}
}
if let Some(home) = dirs::home_dir() {
out.push(home.join("scoop").join("shims"));
out.push(home.join(".bun").join("bin"));
}
out.extend(registry_path_dirs());
out
}
#[cfg(not(windows))]
fn extra_search_dirs() -> Vec<PathBuf> {
let mut out = vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
];
if let Some(home) = dirs::home_dir() {
out.push(home.join(".npm-global").join("bin"));
out.push(home.join(".local").join("bin"));
}
out
}
/// Reads the user and machine PATH values from the registry via `reg.exe` so a PATH edit
/// made after this process started (npm install adds `%APPDATA%\npm`) is still honoured.
#[cfg(windows)]
fn registry_path_dirs() -> Vec<PathBuf> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut out = Vec::new();
let keys = [
r"HKCU\Environment",
r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
];
for key in keys {
let output = std::process::Command::new("reg")
.args(["query", key, "/v", "Path"])
.creation_flags(CREATE_NO_WINDOW)
.output();
let Ok(output) = output else { continue };
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("Path") {
continue;
}
let Some(idx) = trimmed.find("REG_") else { continue };
let rest = &trimmed[idx..];
let Some(space) = rest.find(char::is_whitespace) else { continue };
let value = rest[space..].trim();
for part in value.split(';') {
let expanded = expand_env(part.trim());
if !expanded.is_empty() {
out.push(PathBuf::from(expanded));
}
}
}
}
out
}
#[cfg(windows)]
fn expand_env(value: &str) -> String {
let mut result = String::with_capacity(value.len());
let mut rest = value;
while let Some(start) = rest.find('%') {
result.push_str(&rest[..start]);
let after = &rest[start + 1..];
match after.find('%') {
Some(end) => {
let name = &after[..end];
match env::var(name) {
Ok(v) => result.push_str(&v),
Err(_) => {
result.push('%');
result.push_str(name);
result.push('%');
}
}
rest = &after[end + 1..];
}
None => {
result.push_str(&rest[start..]);
rest = "";
}
}
}
result.push_str(rest);
result
}
/// Runs a codeburn subcommand in the user's terminal emulator so they can see the output.
/// Linux: tries `x-terminal-emulator`, `gnome-terminal`, `konsole`, then falls back to a
/// detached headless spawn. Windows: opens a console via `cmd /C start`. Never
/// interpolates through a shell -- argv throughout.
pub fn spawn_in_terminal(app: &AppHandle, subcommand: &[&str]) -> Result<()> {
let cli = CodeburnCli::resolve();
spawn_program_in_terminal(app, &cli, subcommand)
}
/// The Plan view's "Connect Claude" runs Claude Code's own login flow, not codeburn. The
/// bare name is deliberate: on Windows the console shell resolves `claude.exe` (native
/// installer) or `claude.cmd` (npm) through PATHEXT.
pub fn spawn_claude_login(app: &AppHandle) -> Result<()> {
let cli = CodeburnCli {
program: "claude".to_string(),
extra_args: vec![],
};
spawn_program_in_terminal(app, &cli, &["login"])
}
fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[&str]) -> Result<()> {
if !subcommand.iter().all(|s| is_safe_arg(s)) {
bail!("unsafe subcommand argument");
}
#[cfg(target_os = "linux")]
{
let terminals: [&[&str]; 4] = [
&["x-terminal-emulator", "-e"],
&["gnome-terminal", "--", "bash", "-lc"],
&["konsole", "-e"],
&["xterm", "-e"],
];
for term in &terminals {
let program = term[0];
let extras = &term[1..];
if which::which(program).is_ok() {
let mut command_parts: Vec<String> = vec![cli.program.clone()];
command_parts.extend(cli.extra_args.clone());
command_parts.extend(subcommand.iter().map(|s| s.to_string()));
// gnome-terminal wants the whole command as a single argv after `--`
// followed by `bash -lc`. The allowlist guarantees no quoting is needed.
let composite = command_parts.join(" ");
let mut cmd = std::process::Command::new(program);
cmd.args(extras);
cmd.arg(&composite);
cmd.spawn().with_context(|| format!("failed to launch {}", program))?;
return Ok(());
}
}
// Fallback: run detached, output lost -- better than silently doing nothing.
std::process::Command::new(&cli.program)
.args(&cli.extra_args)
.args(subcommand)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| "no terminal emulator found, detached spawn also failed")?;
}
#[cfg(target_os = "windows")]
{
// `start` treats the first quoted argument as the window title, so we pass an
// explicit empty title. `/K` keeps the console open for non-interactive commands
// (export) so the user can read where the file went; the TUI (report/optimize)
// owns the window until the user quits it either way.
let is_codeburn = cli.program.starts_with("codeburn");
let program = if std::path::Path::new(&cli.program).is_absolute() || !is_codeburn {
cli.program.clone()
} else {
locate_cli().unwrap_or_else(|| cli.program.clone())
};
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/C").arg("start").arg("").arg("cmd").arg("/K").arg(&program);
for a in &cli.extra_args {
cmd.arg(a);
}
for a in subcommand {
cmd.arg(a);
}
cmd.spawn().with_context(|| "failed to open cmd.exe")?;
}
#[cfg(target_os = "macos")]
{
// macOS isn't our target for this app (Swift handles Mac), but keep dev-on-Mac working.
std::process::Command::new(&cli.program)
.args(&cli.extra_args)
.args(subcommand)
.spawn()
.with_context(|| format!("failed to spawn {}", cli.program))?;
}
Ok(())
}
/// Minimal dependency: we only use `which` inside spawn_in_terminal on Linux. Vendored here
/// so the crate graph stays tiny. Gated so the unused-function warning doesn't fire on Mac
/// or Windows builds.
#[cfg(target_os = "linux")]
mod which {
use std::env;
use std::path::PathBuf;
pub fn which(program: &str) -> Result<PathBuf, ()> {
let path = env::var_os("PATH").ok_or(())?;
for dir in env::split_paths(&path) {
let candidate = dir.join(program);
if candidate.is_file() {
return Ok(candidate);
}
}
Err(())
}
}

View file

@ -0,0 +1,161 @@
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CurrencyConfig {
#[serde(default, flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
fn codeburn_config_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".config/codeburn"))
.unwrap_or_else(|| PathBuf::from(".codeburn"))
}
fn config_path() -> PathBuf {
codeburn_config_dir().join("config.json")
}
fn lock_path() -> PathBuf {
codeburn_config_dir().join(".config.lock")
}
impl CurrencyConfig {
pub fn load_or_default() -> Self {
match fs::read(config_path()) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn set_currency(&mut self, code: &str, symbol: &str) -> Result<()> {
fs::create_dir_all(codeburn_config_dir())
.with_context(|| "failed to create ~/.config/codeburn")?;
#[cfg(unix)]
let _lock = unix_lock::acquire()?;
#[cfg(windows)]
let _lock = windows_lock::acquire()?;
let mut disk: serde_json::Value = match fs::read(config_path()) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})),
Err(_) => serde_json::json!({}),
};
if code == "USD" {
if let Some(obj) = disk.as_object_mut() {
obj.remove("currency");
}
} else if let Some(obj) = disk.as_object_mut() {
obj.insert(
"currency".into(),
serde_json::json!({ "code": code, "symbol": symbol }),
);
}
let serialized = serde_json::to_vec_pretty(&disk)?;
let tmp = config_path().with_extension("tmp");
{
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp)?;
file.write_all(&serialized)?;
file.flush()?;
}
fs::rename(&tmp, config_path())?;
*self = serde_json::from_value(disk).unwrap_or_default();
Ok(())
}
}
#[cfg(unix)]
mod unix_lock {
use std::fs;
use std::os::fd::AsRawFd;
use anyhow::{anyhow, Context, Result};
pub struct Guard {
_file: fs::File,
}
pub fn acquire() -> Result<Guard> {
let file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(super::lock_path())
.with_context(|| "failed to open config lock")?;
let fd = file.as_raw_fd();
let ret = unsafe { flock(fd, 2) };
if ret != 0 {
return Err(anyhow!("flock failed: {}", std::io::Error::last_os_error()));
}
Ok(Guard { _file: file })
}
extern "C" {
fn flock(fd: i32, operation: i32) -> i32;
}
}
/// Windows has no flock; a create-new lock file gives the same mutual exclusion against
/// the CLI's own writer. Stale locks (crash mid-write) are ignored once older than
/// STALE_LOCK_SECS so a single crash can never wedge currency changes forever.
#[cfg(windows)]
mod windows_lock {
use std::fs;
use std::path::PathBuf;
use std::thread::sleep;
use std::time::{Duration, SystemTime};
use anyhow::{anyhow, Result};
const RETRY_INTERVAL: Duration = Duration::from_millis(40);
const MAX_RETRIES: u32 = 50;
const STALE_LOCK_SECS: u64 = 30;
pub struct Guard {
path: PathBuf,
}
impl Drop for Guard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn acquire() -> Result<Guard> {
let path = super::lock_path();
for _ in 0..MAX_RETRIES {
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(Guard { path }),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if is_stale(&path) {
let _ = fs::remove_file(&path);
continue;
}
sleep(RETRY_INTERVAL);
}
Err(err) => return Err(anyhow!("failed to open config lock: {err}")),
}
}
Err(anyhow!("config lock is held by another process"))
}
fn is_stale(path: &PathBuf) -> bool {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map(|age| age.as_secs() > STALE_LOCK_SECS)
.unwrap_or(false)
}
}

152
windows/src-tauri/src/fx.rs Normal file
View file

@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const FRANKFURTER_URL: &str = "https://api.frankfurter.app/latest?from=USD&to=";
const CACHE_TTL_SECS: u64 = 24 * 3600;
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
/// Defensive bounds on any fetched FX rate. Outside [0.0001, 1_000_000] the rate is either
/// a parser bug or a tampered response; we refuse it so the UI never multiplies a NaN or
/// wild value into displayed costs.
const MIN_VALID_FX_RATE: f64 = 0.0001;
const MAX_VALID_FX_RATE: f64 = 1_000_000.0;
/// Currency metadata the frontend renders against. `rate` is USD -> target; the UI
/// multiplies each raw USD number by `rate` and prefixes `symbol` for display.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrencyApplied {
pub code: String,
pub symbol: String,
pub rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
rate: f64,
saved_at: u64,
}
pub struct FxCache {
entries: Mutex<HashMap<String, Entry>>,
}
impl FxCache {
pub fn new() -> Self {
let entries = load_from_disk().unwrap_or_default();
FxCache {
entries: Mutex::new(entries),
}
}
/// Returns a cached-or-fresh rate. Tries cache first, then Frankfurter if stale. Any
/// response that fails the sanity bounds is dropped and the cached (possibly stale)
/// value is returned instead.
pub async fn rate_for(&self, code: &str) -> Option<f64> {
if code == "USD" {
return Some(1.0);
}
{
let guard = self.entries.lock().ok()?;
if let Some(entry) = guard.get(code) {
if now_secs().saturating_sub(entry.saved_at) < CACHE_TTL_SECS {
return Some(entry.rate);
}
}
}
match fetch_rate(code).await {
Some(fresh) if is_valid(fresh) => {
if let Ok(mut guard) = self.entries.lock() {
guard.insert(
code.to_string(),
Entry {
rate: fresh,
saved_at: now_secs(),
},
);
let _ = save_to_disk(&guard);
}
Some(fresh)
}
_ => {
// Fetch failed or out-of-band; serve stale cached value if any.
let guard = self.entries.lock().ok()?;
guard.get(code).map(|e| e.rate)
}
}
}
}
fn is_valid(rate: f64) -> bool {
rate.is_finite() && (MIN_VALID_FX_RATE..=MAX_VALID_FX_RATE).contains(&rate)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn cache_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("codeburn-menubar")
.join("fx-rates.json")
}
fn load_from_disk() -> Option<HashMap<String, Entry>> {
let bytes = fs::read(cache_path()).ok()?;
let parsed: HashMap<String, Entry> = serde_json::from_slice(&bytes).ok()?;
Some(parsed.into_iter().filter(|(_, e)| is_valid(e.rate)).collect())
}
fn save_to_disk(entries: &HashMap<String, Entry>) -> Option<()> {
let path = cache_path();
let parent = path.parent()?;
fs::create_dir_all(parent).ok()?;
let serialized = serde_json::to_vec(entries).ok()?;
let tmp = path.with_extension("tmp");
fs::write(&tmp, serialized).ok()?;
fs::rename(&tmp, path).ok()?;
Some(())
}
async fn fetch_rate(code: &str) -> Option<f64> {
let client = reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.https_only(true)
.build()
.ok()?;
let url = format!("{}{}", FRANKFURTER_URL, code);
let response = client.get(&url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let body: serde_json::Value = response.json().await.ok()?;
body.get("rates")?.get(code)?.as_f64()
}
/// Prefers a handwritten glyph over whatever Intl returns for a given code, since some
/// locales produce "US$" / "CA$" which reads as noise. Mirrors the Swift symbol override
/// table so both apps display identical strings for the same code.
pub fn symbol_for(code: &str) -> String {
match code {
"USD" | "CAD" | "AUD" | "NZD" | "HKD" | "SGD" | "MXN" => "$".into(),
"EUR" => "\u{20AC}".into(),
"GBP" => "\u{00A3}".into(),
"JPY" | "CNY" => "\u{00A5}".into(),
"KRW" => "\u{20A9}".into(),
"INR" => "\u{20B9}".into(),
"BRL" => "R$".into(),
"CHF" => "CHF".into(),
"SEK" | "DKK" => "kr".into(),
"ZAR" => "R".into(),
_ => code.into(),
}
}

View file

@ -0,0 +1,509 @@
mod autostart;
mod cli;
mod config;
mod fx;
mod plan;
mod tray_badge;
#[cfg(target_os = "linux")]
mod tray_linux;
use std::sync::Mutex;
use std::sync::atomic::{AtomicI64, Ordering};
static LAST_HIDDEN_MS: AtomicI64 = AtomicI64::new(0);
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
#[cfg(target_os = "linux")]
use tauri::Listener;
#[cfg(not(target_os = "linux"))]
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use crate::cli::CodeburnCli;
use crate::config::CurrencyConfig;
use crate::fx::FxCache;
const TRAY_ID: &str = "codeburn-tray";
/// Second tray icon that carries today's spend as text, sitting next to the logo. The
/// closest Windows and Linux panels get to the macOS menubar title.
const BADGE_TRAY_ID: &str = "codeburn-badge";
const POPOVER_LABEL: &str = "popover";
/// Shared application state. Wraps the CLI handle + currency config + FX cache so every
/// Tauri command sees the same instances. Interior Mutex keeps things simple; the state is
/// touched from the main thread (UI) and the Tokio runtime (CLI spawn, HTTP), both of
/// which go through `#[tauri::command]` async functions that acquire the lock briefly.
pub struct AppState {
pub cli: Mutex<CodeburnCli>,
pub config: Mutex<CurrencyConfig>,
pub fx: FxCache,
pub plan: plan::PlanClient,
#[cfg(target_os = "linux")]
pub linux_tray: tray_linux::LinuxTrayHandle,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
#[cfg(target_os = "linux")]
let linux_tray = tray_linux::LinuxTrayHandle::empty();
let state = AppState {
cli: Mutex::new(CodeburnCli::resolve()),
config: Mutex::new(CurrencyConfig::load_or_default()),
fx: FxCache::new(),
plan: plan::PlanClient::new(),
#[cfg(target_os = "linux")]
linux_tray: linux_tray.clone(),
};
app.manage(state);
#[cfg(not(target_os = "linux"))]
build_tray_tauri(app.handle())?;
#[cfg(target_os = "linux")]
init_tray_linux(app.handle().clone(), linux_tray);
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
let _ = window.hide();
#[cfg(target_os = "windows")]
round_window_corners(&window);
}
Ok(())
})
.on_window_event(|window, event| {
match event {
WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _ = window.hide();
}
WindowEvent::Focused(false) => {
LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed);
let _ = window.hide();
}
_ => {}
}
})
.invoke_handler(tauri::generate_handler![
commands::fetch_payload,
commands::cli_status,
commands::set_currency,
commands::open_terminal_command,
commands::open_claude_login,
commands::quit_app,
commands::hide_popover,
commands::set_tray_tooltip,
commands::set_tray_badge,
commands::app_version,
commands::plan_usage,
commands::launch_at_login,
commands::set_launch_at_login,
])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|_app, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
}
#[cfg(not(target_os = "linux"))]
fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
let Some(tray) = app.tray_by_id(TRAY_ID) else {
return Ok(());
};
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?;
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&open,
&refresh,
&theme,
&report,
&PredefinedMenuItem::separator(app)?,
&quit,
],
)?;
tray.set_menu(Some(menu.clone()))?;
tray.set_show_menu_on_left_click(false)?;
let _ = tray.set_tooltip(Some("CodeBurn"));
tray.on_menu_event(on_tray_menu_event);
tray.on_tray_icon_event(on_tray_icon_event);
// The badge icon starts fully transparent and hidden; the frontend shows it once it has
// today's spend. Registering it right after the logo puts it beside the logo in the tray.
let blank = tauri::image::Image::new_owned(
vec![0u8; (BLANK_ICON_SIZE * BLANK_ICON_SIZE * 4) as usize],
BLANK_ICON_SIZE,
BLANK_ICON_SIZE,
);
TrayIconBuilder::with_id(BADGE_TRAY_ID)
.icon(blank)
.tooltip("CodeBurn")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(on_tray_menu_event)
.on_tray_icon_event(on_tray_icon_event)
.build(app)?
.set_visible(false)?;
Ok(())
}
#[cfg(not(target_os = "linux"))]
const BLANK_ICON_SIZE: u32 = 16;
#[cfg(not(target_os = "linux"))]
fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
match event.id.as_ref() {
"quit" => app.exit(0),
"open" => show_popover(app, None),
"refresh" => {
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
let _ = window.emit("codeburn://refresh", ());
}
}
"toggle_theme" => {
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
let _ = window.emit("codeburn://toggle-theme", ());
}
}
"report" => {
let _ = cli::spawn_in_terminal(app, &["report"]);
}
_ => {}
}
}
#[cfg(not(target_os = "linux"))]
fn on_tray_icon_event(tray: &tauri::tray::TrayIcon, event: TrayIconEvent) {
match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
position,
..
}
| TrayIconEvent::DoubleClick {
button: MouseButton::Left,
position,
..
} => {
toggle_popover(tray.app_handle(), Some((position.x as i32, position.y as i32)));
}
_ => {}
}
}
#[cfg(target_os = "linux")]
fn init_tray_linux(app: AppHandle, handle: tray_linux::LinuxTrayHandle) {
// Spawn the SNI tray on the Tokio runtime that Tauri already owns.
let spawn_app = app.clone();
let spawn_handle = handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(err) = tray_linux::spawn(spawn_app, spawn_handle).await {
eprintln!("codeburn: failed to spawn Linux tray: {err}");
}
});
// Left-click on the tray: show popover anchored to the click coordinates.
let activate_app = app.clone();
app.listen_any("codeburn://tray-activate", move |event| {
let anchor = parse_click(event.payload());
toggle_popover(&activate_app, anchor);
});
// Right-click / middle-click: same as left for now. Quit lives in the popover footer.
let secondary_app = app.clone();
app.listen_any("codeburn://tray-secondary", move |event| {
let anchor = parse_click(event.payload());
toggle_popover(&secondary_app, anchor);
});
}
#[cfg(target_os = "linux")]
fn parse_click(payload: &str) -> Option<(i32, i32)> {
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
let x = value.get("x")?.as_i64()? as i32;
let y = value.get("y")?.as_i64()? as i32;
Some((x, y))
}
/// Undecorated windows are square by default; ask DWM for the Windows 11 rounded corner so
/// the acrylic backdrop is clipped to the same shape as the popover card. Silently ignored
/// on Windows 10, where the corners stay square.
#[cfg(target_os = "windows")]
fn round_window_corners(window: &tauri::WebviewWindow) {
use windows_sys::Win32::Graphics::Dwm::{
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
};
let Ok(hwnd) = window.hwnd() else { return };
let preference: u32 = DWMWCP_ROUND as u32;
unsafe {
DwmSetWindowAttribute(
hwnd.0 as _,
DWMWA_WINDOW_CORNER_PREFERENCE as u32,
&preference as *const u32 as *const std::ffi::c_void,
std::mem::size_of::<u32>() as u32,
);
}
}
/// A blur immediately followed by the tray click that caused it would re-open the popover;
/// ignore show requests inside this window after a hide.
const TOGGLE_DEBOUNCE_MS: i64 = 300;
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
return;
};
if window.is_visible().unwrap_or(false) {
LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed);
let _ = window.hide();
return;
}
let last = LAST_HIDDEN_MS.load(Ordering::Relaxed);
if now_ms() - last < TOGGLE_DEBOUNCE_MS {
return;
}
show_popover(app, anchor);
}
fn show_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
return;
};
// Position before showing so the first frame is already in place (no jump).
position_popover(&window, anchor);
let _ = window.show();
let _ = window.unminimize();
position_popover(&window, anchor);
let _ = window.set_focus();
let _ = window.emit("codeburn://shown", ());
}
/// Places the popover against the taskbar / panel edge of the monitor that owns the click
/// (or the cursor, when the request came from a menu). The work area already excludes the
/// taskbar on Windows and panels on Linux, so we never need to guess their heights: the
/// popover sits `MARGIN` inside the work area, horizontally centred on the anchor and
/// clamped to the screen.
fn position_popover(window: &tauri::WebviewWindow, anchor: Option<(i32, i32)>) {
const POPOVER_WIDTH_LOGICAL: f64 = 360.0;
const POPOVER_HEIGHT_LOGICAL: f64 = 660.0;
const MARGIN_LOGICAL: f64 = 8.0;
let point = anchor
.filter(|(x, y)| *x > 0 || *y > 0)
.map(|(x, y)| (x as f64, y as f64))
.or_else(|| window.cursor_position().ok().map(|p| (p.x, p.y)));
let monitor = point
.and_then(|(x, y)| window.monitor_from_point(x, y).ok().flatten())
.or_else(|| window.primary_monitor().ok().flatten());
let Some(monitor) = monitor else {
return;
};
let scale = monitor.scale_factor();
let pop_w = (POPOVER_WIDTH_LOGICAL * scale).round() as i32;
let pop_h = (POPOVER_HEIGHT_LOGICAL * scale).round() as i32;
let margin = (MARGIN_LOGICAL * scale).round() as i32;
let area = monitor.work_area();
let area_x = area.position.x;
let area_y = area.position.y;
let area_w = area.size.width as i32;
let area_h = area.size.height as i32;
let screen = monitor.size();
let screen_pos = monitor.position();
let (anchor_x, anchor_y) = point
.map(|(x, y)| (x as i32, y as i32))
.unwrap_or((area_x + area_w - pop_w / 2 - margin, area_y + area_h));
let min_x = area_x + margin;
let max_x = (area_x + area_w - pop_w - margin).max(min_x);
let x = (anchor_x - pop_w / 2).clamp(min_x, max_x);
// Which edge holds the taskbar? Whichever side the work area was trimmed on. If the
// taskbar is at the top (or the anchor is in the top half with no bottom taskbar) the
// popover drops down from the top edge; otherwise it rises from the bottom edge.
let trimmed_top = area_y > screen_pos.y;
let trimmed_bottom = (area_y + area_h) < (screen_pos.y + screen.height as i32);
let anchor_in_top_half = anchor_y < screen_pos.y + (screen.height as i32) / 2;
let open_downward = trimmed_top || (!trimmed_bottom && anchor_in_top_half);
let y = if open_downward {
area_y + margin
} else {
(area_y + area_h - pop_h - margin).max(area_y + margin)
};
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
}
mod commands {
use super::{AppState, POPOVER_LABEL};
use serde_json::Value;
use tauri::{AppHandle, Manager, State};
#[tauri::command]
pub async fn fetch_payload(
period: String,
provider: String,
include_optimize: bool,
state: State<'_, AppState>,
) -> Result<Value, String> {
let cli = state.cli.lock().map_err(|e| e.to_string())?.clone();
cli.fetch_menubar_payload(&period, &provider, include_optimize)
.await
.map_err(|e| e.to_string())
}
/// Re-resolves the CLI each call so a freshly installed `codeburn` is picked up
/// without restarting the tray app.
#[tauri::command]
pub async fn cli_status(state: State<'_, AppState>) -> Result<crate::cli::CliStatus, String> {
let fresh = crate::cli::CodeburnCli::resolve();
let status = fresh.status().await;
if status.found {
if let Ok(mut guard) = state.cli.lock() {
*guard = fresh;
}
}
Ok(status)
}
#[tauri::command]
pub async fn set_currency(
code: String,
state: State<'_, AppState>,
) -> Result<crate::fx::CurrencyApplied, String> {
let symbol = crate::fx::symbol_for(&code);
let rate = state
.fx
.rate_for(&code)
.await
.ok_or_else(|| format!("Exchange rate for {code} is unavailable right now"))?;
state
.config
.lock()
.map_err(|e| e.to_string())?
.set_currency(&code, &symbol)
.map_err(|e| e.to_string())?;
Ok(crate::fx::CurrencyApplied { code, symbol, rate })
}
#[tauri::command]
pub fn open_terminal_command(app: AppHandle, args: Vec<String>) -> Result<(), String> {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
crate::cli::spawn_in_terminal(&app, &args).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn open_claude_login(app: AppHandle) -> Result<(), String> {
crate::cli::spawn_claude_login(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
#[tauri::command]
pub fn hide_popover(app: AppHandle) {
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
super::LAST_HIDDEN_MS.store(super::now_ms(), std::sync::atomic::Ordering::Relaxed);
let _ = window.hide();
}
}
/// The tray cannot render text on Windows, so today's spend lives in the tooltip.
#[tauri::command]
pub fn set_tray_tooltip(app: AppHandle, text: String) {
#[cfg(not(target_os = "linux"))]
for id in [super::TRAY_ID, super::BADGE_TRAY_ID] {
if let Some(tray) = app.tray_by_id(id) {
let _ = tray.set_tooltip(Some(text.as_str()));
}
}
#[cfg(target_os = "linux")]
{
let _ = (app, text);
}
}
/// `text` is a short spend string ("$87", "142", "1.2K"); `None` hides the badge icon.
#[tauri::command]
pub fn set_tray_badge(app: AppHandle, text: Option<String>) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let Some(badge) = app.tray_by_id(super::BADGE_TRAY_ID) else {
return Ok(());
};
match text.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => {
let icon = crate::tray_badge::render(
t,
crate::tray_badge::small_icon_size(),
crate::tray_badge::taskbar_is_dark(),
);
// Windows can only modify an icon that is currently shown, so show
// first (re-adds the previous bitmap) and then swap the bitmap.
badge.set_visible(true).map_err(|e| e.to_string())?;
badge.set_icon(Some(icon)).map_err(|e| e.to_string())?;
}
None => {
badge.set_visible(false).map_err(|e| e.to_string())?;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = (app, text);
}
Ok(())
}
#[tauri::command]
pub fn app_version(app: AppHandle) -> String {
app.package_info().version.to_string()
}
#[tauri::command]
pub fn launch_at_login() -> bool {
crate::autostart::is_enabled()
}
#[tauri::command]
pub fn set_launch_at_login(enabled: bool) -> Result<bool, String> {
crate::autostart::set_enabled(enabled).map_err(|e| e.to_string())?;
Ok(crate::autostart::is_enabled())
}
#[tauri::command]
pub async fn plan_usage(state: State<'_, AppState>) -> Result<crate::plan::PlanUsage, String> {
state.plan.fetch().await.map_err(|e| e.to_string())
}
}

View file

@ -0,0 +1,6 @@
// Stops an extra console window appearing on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
codeburn_menubar_lib::run()
}

View file

@ -0,0 +1,484 @@
//! Claude subscription usage (the "Plan" insight). Mirrors the macOS SubscriptionClient:
//! read Claude Code's OAuth credentials, call the usage endpoint, refresh once on 401, and
//! keep a rolling snapshot file so a freshly reset window can still show last cycle's final.
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
const CREDENTIALS_RELATIVE_PATH: &str = ".claude/.credentials.json";
const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const REFRESH_URL: &str = "https://platform.claude.com/v1/oauth/token";
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const BETA_HEADER: &str = "oauth-2025-04-20";
const USER_AGENT: &str = "claude-code/2.1.0";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
const SNAPSHOT_FILENAME: &str = "subscription-snapshots.json";
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(30 * 24 * 3600);
const WINDOW_KEYS: [(&str, &str); 4] = [
("five_hour", "5-hour window"),
("seven_day", "7-day total"),
("seven_day_opus", "7-day Opus"),
("seven_day_sonnet", "7-day Sonnet"),
];
#[derive(Debug, Clone, Serialize)]
pub struct PlanWindow {
pub key: String,
pub label: String,
/// 0..100
pub percent: f64,
/// RFC 3339 timestamp of the next reset, when the API supplied one.
pub resets_at: Option<String>,
/// Final percent reached in the immediately prior cycle, from the snapshot store.
pub previous_final: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum PlanUsage {
Ok {
tier: String,
raw_tier: Option<String>,
windows: Vec<PlanWindow>,
fetched_at: String,
},
NoCredentials,
Failed {
message: String,
},
}
pub struct PlanClient {
snapshot_lock: Mutex<()>,
}
impl PlanClient {
pub fn new() -> Self {
PlanClient {
snapshot_lock: Mutex::new(()),
}
}
pub async fn fetch(&self) -> Result<PlanUsage> {
let creds = match load_credentials() {
Ok(Some(c)) => c,
Ok(None) => return Ok(PlanUsage::NoCredentials),
Err(err) => {
return Ok(PlanUsage::Failed {
message: err.to_string(),
})
}
};
let response = match fetch_usage(&creds.access_token).await {
Ok(r) => r,
Err(FetchError::Unauthorized) => {
let Some(refresh) = creds.refresh_token.as_deref().filter(|t| !t.is_empty()) else {
return Ok(PlanUsage::Failed {
message: "Claude session expired and no refresh token is available. Run `claude login`.".into(),
});
};
match refresh_access_token(refresh).await {
Ok(token) => match fetch_usage(&token).await {
Ok(r) => r,
Err(err) => {
return Ok(PlanUsage::Failed {
message: err.to_string(),
})
}
},
Err(err) => {
return Ok(PlanUsage::Failed {
message: format!("Token refresh failed: {err}"),
})
}
}
}
Err(err) => {
return Ok(PlanUsage::Failed {
message: err.to_string(),
})
}
};
let now = SystemTime::now();
let mut windows = Vec::new();
for (key, label) in WINDOW_KEYS {
let Some(window) = response.window(key) else { continue };
let Some(percent) = window.utilization else { continue };
let resets_at = window.resets_at.clone().filter(|s| !s.is_empty());
let previous_final = {
let _guard = self.snapshot_lock.lock().await;
if let Some(reset) = resets_at.as_deref() {
record_snapshot(key, percent, reset, now);
previous_window_final(key, reset)
} else {
None
}
};
windows.push(PlanWindow {
key: key.to_string(),
label: label.to_string(),
percent: percent.clamp(0.0, 100.0),
resets_at,
previous_final,
});
}
Ok(PlanUsage::Ok {
tier: tier_display(creds.rate_limit_tier.as_deref()),
raw_tier: creds.rate_limit_tier,
windows,
fetched_at: to_rfc3339(now),
})
}
}
// ---- credentials -----------------------------------------------------------------------
struct StoredCredentials {
access_token: String,
refresh_token: Option<String>,
rate_limit_tier: Option<String>,
}
#[derive(Deserialize)]
struct CredentialsRoot {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: Option<OAuthBlock>,
}
#[derive(Deserialize)]
struct OAuthBlock {
#[serde(rename = "accessToken")]
access_token: Option<String>,
#[serde(rename = "refreshToken")]
refresh_token: Option<String>,
#[serde(rename = "rateLimitTier")]
rate_limit_tier: Option<String>,
}
fn credentials_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(CREDENTIALS_RELATIVE_PATH))
}
/// Ok(None) when the file does not exist (user never logged in); Err for malformed data.
fn load_credentials() -> Result<Option<StoredCredentials>> {
let Some(path) = credentials_path() else {
return Ok(None);
};
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => return Ok(None),
};
if meta.file_type().is_symlink() {
bail!("credentials file is a symlink; refusing to read it");
}
if meta.len() > MAX_CREDENTIAL_BYTES {
bail!("credentials file is unexpectedly large");
}
let bytes = fs::read(&path).with_context(|| "failed to read Claude credentials")?;
let root: CredentialsRoot =
serde_json::from_slice(&bytes).with_context(|| "Claude credentials are malformed")?;
let Some(oauth) = root.claude_ai_oauth else {
return Ok(None);
};
let token = oauth.access_token.unwrap_or_default().trim().to_string();
if token.is_empty() {
return Ok(None);
}
Ok(Some(StoredCredentials {
access_token: token,
refresh_token: oauth.refresh_token,
rate_limit_tier: oauth.rate_limit_tier,
}))
}
fn tier_display(raw: Option<&str>) -> String {
let Some(raw) = raw.map(|r| r.to_lowercase()) else {
return "Subscription".into();
};
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") {
return "Max 20x".into();
}
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") {
return "Max 5x".into();
}
if raw.contains("max") {
return "Max 5x".into();
}
if raw.contains("pro") {
return "Pro".into();
}
if raw.contains("team") {
return "Team".into();
}
if raw.contains("enterprise") {
return "Enterprise".into();
}
"Subscription".into()
}
// ---- HTTP ------------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct UsageResponse {
five_hour: Option<Window>,
seven_day: Option<Window>,
seven_day_opus: Option<Window>,
seven_day_sonnet: Option<Window>,
}
impl UsageResponse {
fn window(&self, key: &str) -> Option<&Window> {
match key {
"five_hour" => self.five_hour.as_ref(),
"seven_day" => self.seven_day.as_ref(),
"seven_day_opus" => self.seven_day_opus.as_ref(),
"seven_day_sonnet" => self.seven_day_sonnet.as_ref(),
_ => None,
}
}
}
#[derive(Debug, Deserialize)]
struct Window {
utilization: Option<f64>,
resets_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenRefreshResponse {
access_token: String,
}
#[derive(Debug, thiserror::Error)]
enum FetchError {
#[error("Claude session is no longer authorized")]
Unauthorized,
#[error("Usage fetch failed ({0}){1}")]
Http(u16, String),
#[error("{0}")]
Other(String),
}
fn client() -> Result<reqwest::Client, FetchError> {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.https_only(true)
.build()
.map_err(|e| FetchError::Other(e.to_string()))
}
async fn fetch_usage(token: &str) -> Result<UsageResponse, FetchError> {
let response = client()?
.get(USAGE_URL)
.bearer_auth(token)
.header("Accept", "application/json")
.header("anthropic-beta", BETA_HEADER)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| FetchError::Other(e.to_string()))?;
let status = response.status();
if status.as_u16() == 401 {
return Err(FetchError::Unauthorized);
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let detail = if body.is_empty() { String::new() } else { format!(": {}", truncate(&body, 200)) };
return Err(FetchError::Http(status.as_u16(), detail));
}
response
.json::<UsageResponse>()
.await
.map_err(|e| FetchError::Other(format!("Decode failed: {e}")))
}
async fn refresh_access_token(refresh_token: &str) -> Result<String> {
let response = client()
.map_err(|e| anyhow!(e.to_string()))?
.post(REFRESH_URL)
.header("Accept", "application/json")
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", OAUTH_CLIENT_ID),
])
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
bail!("{} {}", status.as_u16(), truncate(&body, 200));
}
let decoded: TokenRefreshResponse = response.json().await?;
Ok(decoded.access_token)
}
fn truncate(text: &str, max: usize) -> String {
let mut out: String = text.chars().take(max).collect();
if text.chars().count() > max {
out.push_str("...");
}
out
}
// ---- snapshots -------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Snapshot {
#[serde(rename = "windowKey")]
window_key: String,
percent: f64,
#[serde(rename = "resetsAt")]
resets_at: String,
#[serde(rename = "capturedAt")]
captured_at: String,
#[serde(rename = "effectiveTokens")]
effective_tokens: Option<f64>,
}
fn snapshots_path() -> PathBuf {
let dir = std::env::var_os("CODEBURN_CACHE_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache").join("codeburn")))
.unwrap_or_else(|| PathBuf::from("."));
dir.join(SNAPSHOT_FILENAME)
}
fn load_snapshots() -> Vec<Snapshot> {
fs::read(snapshots_path())
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn save_snapshots(all: &[Snapshot]) {
let path = snapshots_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(bytes) = serde_json::to_vec_pretty(all) {
let tmp = path.with_extension("tmp");
if fs::write(&tmp, bytes).is_ok() {
let _ = fs::rename(&tmp, &path);
}
}
}
fn record_snapshot(window_key: &str, percent: f64, resets_at: &str, now: SystemTime) {
let mut all = load_snapshots();
match all
.iter_mut()
.find(|s| s.window_key == window_key && s.resets_at == resets_at)
{
Some(existing) => {
if percent > existing.percent {
existing.percent = percent;
existing.captured_at = to_rfc3339(now);
}
}
None => all.push(Snapshot {
window_key: window_key.to_string(),
percent,
resets_at: resets_at.to_string(),
captured_at: to_rfc3339(now),
effective_tokens: None,
}),
}
let cutoff = now.checked_sub(SNAPSHOT_RETENTION).unwrap_or(UNIX_EPOCH);
all.retain(|s| parse_rfc3339(&s.captured_at).map(|t| t >= cutoff).unwrap_or(true));
save_snapshots(&all);
}
fn previous_window_final(window_key: &str, current_resets_at: &str) -> Option<f64> {
let current = parse_rfc3339(current_resets_at)?;
let all = load_snapshots();
let priors: Vec<(SystemTime, f64)> = all
.iter()
.filter(|s| s.window_key == window_key)
.filter_map(|s| parse_rfc3339(&s.resets_at).map(|t| (t, s.percent)))
.filter(|(t, _)| *t < current)
.collect();
let latest = priors.iter().map(|(t, _)| *t).max()?;
priors
.iter()
.filter(|(t, _)| *t == latest)
.map(|(_, p)| *p)
.fold(None, |acc: Option<f64>, p| Some(acc.map_or(p, |a| a.max(p))))
}
// ---- time helpers (RFC 3339 without pulling in chrono) --------------------------------
fn to_rfc3339(t: SystemTime) -> String {
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let (y, m, d, hh, mm, ss) = civil_from_unix(secs);
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
/// Accepts `2026-08-18T10:00:00Z`, with optional fractional seconds and `+hh:mm` offsets.
fn parse_rfc3339(text: &str) -> Option<SystemTime> {
let bytes = text.as_bytes();
if bytes.len() < 19 {
return None;
}
let num = |a: usize, b: usize| text.get(a..b)?.parse::<i64>().ok();
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
let (h, mi, s) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
let mut rest = &text[19..];
if rest.starts_with('.') {
let end = rest[1..]
.find(|c: char| !c.is_ascii_digit())
.map(|i| i + 1)
.unwrap_or(rest.len());
rest = &rest[end..];
}
let offset_secs = match rest {
"" | "Z" | "z" => 0,
_ => {
let sign = if rest.starts_with('-') { -1 } else { 1 };
let oh = rest.get(1..3)?.parse::<i64>().ok()?;
let om = rest.get(4..6)?.parse::<i64>().ok()?;
sign * (oh * 3600 + om * 60)
}
};
let unix = unix_from_civil(y, mo, d) + h * 3600 + mi * 60 + s - offset_secs;
if unix < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(unix as u64))
}
fn unix_from_civil(y: i64, m: i64, d: i64) -> i64 {
// Howard Hinnant's days_from_civil.
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe - 719_468) * 86_400
}
fn civil_from_unix(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d, rem / 3600, (rem % 3600) / 60, rem % 60)
}

View file

@ -0,0 +1,246 @@
//! Renders today's spend into the tray icon. Windows and most Linux panels cannot place a
//! title next to a tray icon the way the macOS menubar does, so the number becomes the icon:
//! a 4x7 pixel font drawn at the panel's native small-icon size, so it stays crisp instead
//! of being a scaled-down bitmap.
use tauri::image::Image;
const GLYPH_HEIGHT: usize = 7;
const BASE_ICON_SIZE: u32 = 16;
const GLYPH_GAP: usize = 1;
/// Brand accent on a light taskbar, the lighter ember on a dark one.
const ACCENT_LIGHT_TASKBAR: [u8; 3] = [0xC9, 0x52, 0x1D];
const ACCENT_DARK_TASKBAR: [u8; 3] = [0xF0, 0x8A, 0x55];
struct Glyph {
width: usize,
rows: [&'static str; GLYPH_HEIGHT],
}
fn glyph(c: char) -> Option<Glyph> {
let g = |width: usize, rows: [&'static str; GLYPH_HEIGHT]| Some(Glyph { width, rows });
match c {
'0' => g(4, [".##.", "#..#", "#..#", "#..#", "#..#", "#..#", ".##."]),
'1' => g(3, [".#.", "##.", ".#.", ".#.", ".#.", ".#.", "###"]),
'2' => g(4, [".##.", "#..#", "...#", "..#.", ".#..", "#...", "####"]),
'3' => g(4, ["###.", "...#", "...#", ".##.", "...#", "...#", "###."]),
'4' => g(4, ["#..#", "#..#", "#..#", "####", "...#", "...#", "...#"]),
'5' => g(4, ["####", "#...", "#...", "###.", "...#", "...#", "###."]),
'6' => g(4, [".##.", "#...", "#...", "###.", "#..#", "#..#", ".##."]),
'7' => g(4, ["####", "...#", "..#.", "..#.", ".#..", ".#..", ".#.."]),
'8' => g(4, [".##.", "#..#", "#..#", ".##.", "#..#", "#..#", ".##."]),
'9' => g(4, [".##.", "#..#", "#..#", ".###", "...#", "...#", ".##."]),
'$' => g(4, ["..#.", ".###", "#.#.", ".##.", ".#.#", "###.", "..#."]),
'K' => g(4, ["#..#", "#.#.", "##..", "#...", "##..", "#.#.", "#..#"]),
'M' => g(4, ["#..#", "####", "####", "#..#", "#..#", "#..#", "#..#"]),
'.' => g(1, [".", ".", ".", ".", ".", ".", "#"]),
_ => None,
}
}
fn text_width(text: &str) -> usize {
let glyphs: Vec<Glyph> = text.chars().filter_map(glyph).collect();
if glyphs.is_empty() {
return 0;
}
glyphs.iter().map(|g| g.width).sum::<usize>() + GLYPH_GAP * (glyphs.len() - 1)
}
/// Draws `text` centred in a `size` x `size` RGBA icon. Prefers anti-aliased bold system
/// text (far more legible at 16px than 1px pixel strokes); falls back to the pixel font
/// when no usable font file is present.
pub fn render(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
if let Some(image) = render_with_font(text, size, color) {
return image;
}
// The pixel font cannot shrink, so trim from the right until the string fits.
let mut trimmed: String = text.to_string();
while !fits(&trimmed) && trimmed.pop().is_some() {}
render_pixel_font(&trimmed, size, dark_taskbar)
}
/// Bold sans faces shipped with Windows, best first. Bahnschrift's condensed numerals fit
/// four glyphs into 16px at a larger size than Segoe UI Bold does.
#[cfg(target_os = "windows")]
const FONT_CANDIDATES: [&str; 3] = [
r"C:\Windows\Fonts\bahnschrift.ttf",
r"C:\Windows\Fonts\segoeuib.ttf",
r"C:\Windows\Fonts\arialbd.ttf",
];
#[cfg(not(target_os = "windows"))]
const FONT_CANDIDATES: [&str; 3] = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
];
/// Largest and smallest text sizes tried, as a fraction of the icon size.
const FONT_MAX_FRACTION: f32 = 0.95;
const FONT_MIN_FRACTION: f32 = 0.5;
const FONT_STEP_PX: f32 = 0.5;
fn load_font() -> Option<fontdue::Font> {
for path in FONT_CANDIDATES {
if let Ok(bytes) = std::fs::read(path) {
if let Ok(font) = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) {
return Some(font);
}
}
}
None
}
struct Raster {
metrics: fontdue::Metrics,
bitmap: Vec<u8>,
}
fn layout(font: &fontdue::Font, text: &str, px: f32) -> (Vec<Raster>, f32, i32, i32) {
let glyphs: Vec<Raster> = text
.chars()
.map(|c| {
let (metrics, bitmap) = font.rasterize(c, px);
Raster { metrics, bitmap }
})
.collect();
let width: f32 = glyphs.iter().map(|g| g.metrics.advance_width).sum();
let top = glyphs.iter().map(|g| g.metrics.height as i32 + g.metrics.ymin).max().unwrap_or(0);
let bottom = glyphs.iter().map(|g| g.metrics.ymin).min().unwrap_or(0);
(glyphs, width, top, bottom)
}
fn render_with_font(text: &str, size: u32, color: [u8; 3]) -> Option<Image<'static>> {
let font = load_font()?;
let limit = size as f32;
let mut px = limit * FONT_MAX_FRACTION;
let mut chosen = None;
while px >= limit * FONT_MIN_FRACTION {
let (glyphs, width, top, bottom) = layout(&font, text, px);
let height = (top - bottom) as f32;
if width <= limit && height <= limit {
chosen = Some((glyphs, width, top, bottom));
break;
}
px -= FONT_STEP_PX;
}
let (glyphs, width, top, bottom) = chosen?;
let mut rgba = vec![0u8; (size * size * 4) as usize];
let height = top - bottom;
let x0 = ((limit - width) / 2.0).round();
let baseline = ((size as i32 - height) / 2) + top;
let mut pen = x0;
for g in &glyphs {
let gx = pen.round() as i32 + g.metrics.xmin;
let gy = baseline - g.metrics.height as i32 - g.metrics.ymin;
for row in 0..g.metrics.height {
for col in 0..g.metrics.width {
let alpha = g.bitmap[row * g.metrics.width + col];
if alpha == 0 {
continue;
}
let px_x = gx + col as i32;
let px_y = gy + row as i32;
if px_x < 0 || px_y < 0 || px_x >= size as i32 || px_y >= size as i32 {
continue;
}
let i = ((px_y as u32 * size + px_x as u32) * 4) as usize;
let existing = rgba[i + 3];
let merged = existing.max(alpha);
rgba[i] = color[0];
rgba[i + 1] = color[1];
rgba[i + 2] = color[2];
rgba[i + 3] = merged;
}
}
pen += g.metrics.advance_width;
}
Some(Image::new_owned(rgba, size, size))
}
fn render_pixel_font(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
let size = size.max(BASE_ICON_SIZE);
let scale = (size / BASE_ICON_SIZE).max(1) as usize;
let mut rgba = vec![0u8; (size * size * 4) as usize];
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
let width = text_width(text) * scale;
let height = GLYPH_HEIGHT * scale;
let mut x = (size as usize).saturating_sub(width) / 2;
let y0 = (size as usize).saturating_sub(height) / 2;
for g in text.chars().filter_map(glyph) {
for (row, bits) in g.rows.iter().enumerate() {
for (col, ch) in bits.chars().enumerate() {
if ch != '#' {
continue;
}
for dy in 0..scale {
for dx in 0..scale {
let px = x + col * scale + dx;
let py = y0 + row * scale + dy;
if px < size as usize && py < size as usize {
let i = (py * size as usize + px) * 4;
rgba[i] = color[0];
rgba[i + 1] = color[1];
rgba[i + 2] = color[2];
rgba[i + 3] = 0xFF;
}
}
}
}
}
x += (g.width + GLYPH_GAP) * scale;
}
Image::new_owned(rgba, size, size)
}
/// Whether the text fits the 16px pixel-font grid (the font path scales itself to fit).
fn fits(text: &str) -> bool {
text_width(text) <= BASE_ICON_SIZE as usize
}
/// The panel's small-icon size in physical pixels (16 at 100%, 20 at 125%, 24 at 150%).
#[cfg(target_os = "windows")]
pub fn small_icon_size() -> u32 {
use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON};
let px = unsafe { GetSystemMetrics(SM_CXSMICON) };
if px > 0 { px as u32 } else { BASE_ICON_SIZE }
}
#[cfg(not(target_os = "windows"))]
pub fn small_icon_size() -> u32 {
22
}
/// Windows keeps the taskbar theme separate from the app theme; the number must contrast
/// with the taskbar, not the popover.
#[cfg(target_os = "windows")]
pub fn taskbar_is_dark() -> bool {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = std::process::Command::new("reg")
.args([
"query",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"/v",
"SystemUsesLightTheme",
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
// "0x0" means the system (taskbar) uses the dark theme.
text.lines().any(|l| l.contains("SystemUsesLightTheme") && l.trim_end().ends_with("0x0"))
}
Err(_) => true,
}
}
#[cfg(not(target_os = "windows"))]
pub fn taskbar_is_dark() -> bool {
true
}

View file

@ -0,0 +1,139 @@
use std::sync::{Arc, Mutex};
use ksni::{Category, Icon, Status, ToolTip, Tray, TrayMethods};
use tauri::{AppHandle, Emitter};
/// StatusNotifierItem-backed tray for Linux. Bypasses libappindicator so left-click
/// fires `activate(x, y)` with real screen coordinates, which is what Tauri's Linux
/// tray path cannot deliver. See tauri-apps/tauri#7283 for the upstream gap.
///
/// No menu() is exported. Exporting a menu causes most SNI hosts (notably
/// gnome-shell-extension-appindicator) to swallow left-click as a menu-open and
/// never fire Activate. Quit/Refresh/Open Full Report live in the popover footer.
pub struct CodeburnTray {
app: AppHandle,
title: String,
icon: Vec<Icon>,
}
impl CodeburnTray {
fn new(app: AppHandle, icon: Vec<Icon>) -> Self {
Self {
app,
title: "CodeBurn".to_string(),
icon,
}
}
}
impl Tray for CodeburnTray {
fn id(&self) -> String {
"org.agentseal.codeburn".to_string()
}
fn title(&self) -> String {
self.title.clone()
}
fn category(&self) -> Category {
Category::ApplicationStatus
}
fn status(&self) -> Status {
Status::Active
}
fn icon_pixmap(&self) -> Vec<Icon> {
self.icon.clone()
}
fn tool_tip(&self) -> ToolTip {
ToolTip {
icon_name: String::new(),
icon_pixmap: Vec::new(),
title: "CodeBurn".to_string(),
description: self.title.clone(),
}
}
fn activate(&mut self, x: i32, y: i32) {
let _ = self
.app
.emit("codeburn://tray-activate", TrayClick { x, y });
}
fn secondary_activate(&mut self, x: i32, y: i32) {
let _ = self
.app
.emit("codeburn://tray-secondary", TrayClick { x, y });
}
}
#[derive(Clone, serde::Serialize)]
struct TrayClick {
x: i32,
y: i32,
}
/// Type-erased handle for the Linux tray so callers can push title updates without
/// naming the `ksni::Handle<CodeburnTray>` generic parameter across module boundaries.
#[derive(Clone)]
pub struct LinuxTrayHandle {
inner: Arc<Mutex<Option<ksni::Handle<CodeburnTray>>>>,
}
impl LinuxTrayHandle {
pub fn empty() -> Self {
Self {
inner: Arc::new(Mutex::new(None)),
}
}
fn set(&self, handle: ksni::Handle<CodeburnTray>) {
if let Ok(mut guard) = self.inner.lock() {
*guard = Some(handle);
}
}
}
/// Decode the bundled tray.png into ARGB32 pixels that the SNI spec expects.
/// Falls back to an empty icon list (host shows a broken-icon placeholder) if the
/// asset can't be decoded. We'd rather render a blank icon than crash the tray.
fn load_icon() -> Vec<Icon> {
// Embedded at build time so the binary is self-contained.
let bytes = include_bytes!("../icons/tray.png");
let Ok(decoder) = png::Decoder::new(bytes.as_slice()).read_info().map_err(|_| ()) else {
return Vec::new();
};
decode_png(decoder)
}
fn decode_png(mut reader: png::Reader<&[u8]>) -> Vec<Icon> {
let info = reader.info().clone();
let width = info.width as i32;
let height = info.height as i32;
let mut buf = vec![0u8; reader.output_buffer_size()];
if reader.next_frame(&mut buf).is_err() {
return Vec::new();
}
// SNI expects ARGB32 in network byte order. PNG decoder gives RGBA8.
let pixel_count = (width as usize) * (height as usize);
let mut argb = Vec::with_capacity(pixel_count * 4);
for chunk in buf.chunks_exact(4) {
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
argb.extend_from_slice(&[a, r, g, b]);
}
vec![Icon {
width,
height,
data: argb,
}]
}
pub async fn spawn(app: AppHandle, handle_out: LinuxTrayHandle) -> anyhow::Result<()> {
let tray = CodeburnTray::new(app, load_icon());
let handle = tray.spawn().await?;
handle_out.set(handle);
Ok(())
}

View file

@ -0,0 +1,64 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeBurn Menubar",
"version": "0.9.20",
"identifier": "org.agentseal.codeburn-menubar",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "popover",
"title": "CodeBurn",
"width": 360,
"height": 660,
"decorations": false,
"transparent": true,
"resizable": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"visible": false,
"focus": false,
"shadow": true,
"windowEffects": {
"effects": ["acrylic"],
"state": "active"
}
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: https://api.frankfurter.app"
},
"trayIcon": {
"id": "codeburn-tray",
"iconPath": "icons/tray.png",
"iconAsTemplate": false,
"tooltip": "CodeBurn"
}
},
"bundle": {
"active": true,
"category": "Utility",
"shortDescription": "AI coding cost tracker",
"longDescription": "Shows today's AI coding spend in your system tray. Popover breaks down cost by activity, model, and provider across Claude Code, Cursor, Codex, and more.",
"publisher": "AgentSeal",
"homepage": "https://github.com/getagentseal/codeburn",
"targets": ["deb", "rpm", "appimage", "msi"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"]
}
}
}
}

352
windows/src/App.tsx Normal file
View file

@ -0,0 +1,352 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import type { MenubarPayload } from './lib/payload'
import type { CurrencyState } from './lib/currency'
import { USD, formatCurrency, trayBadgeText } from './lib/currency'
import { PayloadCache } from './lib/cache'
import { relativePast } from './lib/dates'
import { applyTheme, currentTheme, readSetting, writeSetting } from './lib/settings'
import { AgentTabStrip, detectedProviders } from './components/AgentTabStrip'
import type { Provider } from './components/AgentTabStrip'
import { ModelsSection } from './components/ModelsSection'
import { InsightPills, INSIGHT_ORDER, isInsightMode, type InsightMode } from './components/InsightPills'
import { TrendInsight } from './components/TrendInsight'
import { ForecastInsight } from './components/ForecastInsight'
import { PulseInsight } from './components/PulseInsight'
import { StatsInsight } from './components/StatsInsight'
import { PlanInsight } from './components/PlanInsight'
import { FindingsSection } from './components/FindingsSection'
import { ActivitySection } from './components/ActivitySection'
import { LoadingOverlay } from './components/LoadingOverlay'
import { EmptyProviderState } from './components/EmptyProviderState'
import { NoDataState } from './components/NoDataState'
import { SetupState, type CliStatus } from './components/SetupState'
import { StarBanner } from './components/StarBanner'
import { HeroSection } from './components/HeroSection'
import { PeriodTabs, PERIOD_LABELS } from './components/PeriodTabs'
import type { Period } from './components/PeriodTabs'
import { FooterBar } from './components/FooterBar'
import { ErrorToast } from './components/ErrorToast'
import { SettingsPanel, type ThemeChoice } from './components/SettingsPanel'
const payloadCache = new PayloadCache<MenubarPayload>()
/// Background cadence. Every tick refreshes today/all (tray tooltip, provider badges) and
/// the visible period/provider; entries younger than STALE_MS are left alone when the
/// popover is re-opened.
const REFRESH_INTERVAL_MS = 60_000
const STALE_MS = 60_000
type FetchOptions = {
includeOptimize: boolean
showOverlay: boolean
}
export function App() {
const [period, setPeriod] = useState<Period>('today')
const [provider, setProvider] = useState<Provider>('all')
const [payload, setPayload] = useState<MenubarPayload | null>(null)
const [todayPayload, setTodayPayload] = useState<MenubarPayload | null>(null)
const [currency, setCurrency] = useState<CurrencyState>(USD)
const [overlay, setOverlay] = useState(false)
const [error, setError] = useState<string | null>(null)
const [insight, setInsight] = useState<InsightMode>(() => {
const saved = readSetting('insight')
return isInsightMode(saved) ? saved : 'trend'
})
const [cliStatus, setCliStatus] = useState<CliStatus | null>(null)
const [cliChecking, setCliChecking] = useState(false)
const [version, setVersion] = useState('')
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const [theme, setTheme] = useState(() => currentTheme())
const [trayBadge, setTrayBadge] = useState(() => readSetting('trayBadge') !== 'off')
const [showSettings, setShowSettings] = useState(false)
const [themeChoice, setThemeChoice] = useState<ThemeChoice>(() => {
const saved = readSetting('theme')
return saved === 'dark' || saved === 'light' ? saved : 'system'
})
const selection = useRef({ period, provider })
selection.current = { period, provider }
const fetchKey = useCallback(async (p: Period, prov: Provider, opts: FetchOptions) => {
if (payloadCache.isInFlight(p, prov)) return
payloadCache.markInFlight(p, prov)
const isSelected = () => selection.current.period === p && selection.current.provider === prov
if (opts.showOverlay && isSelected()) setOverlay(true)
try {
const json = await invoke<MenubarPayload>('fetch_payload', {
period: p,
provider: prov,
includeOptimize: opts.includeOptimize,
})
// A quiet (no-optimize) refresh must not wipe findings a previous full fetch had.
if (!opts.includeOptimize) {
const previous = payloadCache.get(p, prov)
if (previous) json.optimize = previous.optimize
}
payloadCache.set(p, prov, json)
if (isSelected()) setPayload(json)
if (p === 'today' && prov === 'all') setTodayPayload(json)
setLastUpdated(new Date())
setCliStatus(prev => (prev && !(prev.found && prev.compatible) ? { ...prev, found: true, compatible: true, error: null } : prev))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('CLI not found')) {
const status = await invoke<CliStatus>('cli_status').catch(() => null)
if (status) setCliStatus(status)
} else if (isSelected()) {
setError(message)
}
} finally {
payloadCache.clearInFlight(p, prov)
if (isSelected()) setOverlay(false)
}
}, [])
const refreshAll = useCallback(async (opts: FetchOptions) => {
const { period: p, provider: prov } = selection.current
if (!(p === 'today' && prov === 'all')) {
fetchKey('today', 'all', { includeOptimize: false, showOverlay: false })
}
await fetchKey(p, prov, opts)
}, [fetchKey])
const probeCli = useCallback(async () => {
setCliChecking(true)
try {
setCliStatus(await invoke<CliStatus>('cli_status'))
} catch {
// Leave the status unknown; the data views already tell the user if fetches fail.
} finally {
setCliChecking(false)
}
}, [])
const checkCli = useCallback(async () => {
setCliChecking(true)
try {
const status = await invoke<CliStatus>('cli_status')
setCliStatus(status)
if (status.found && status.compatible) {
refreshAll({ includeOptimize: true, showOverlay: true })
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setCliChecking(false)
}
}, [refreshAll])
useEffect(() => {
invoke<string>('app_version').then(setVersion).catch(() => {})
refreshAll({ includeOptimize: true, showOverlay: true })
const id = setInterval(() => refreshAll({ includeOptimize: true, showOverlay: false }), REFRESH_INTERVAL_MS)
return () => clearInterval(id)
}, [refreshAll])
useEffect(() => {
const cached = payloadCache.get(period, provider)
setPayload(cached)
if (!cached) {
fetchKey(period, provider, { includeOptimize: true, showOverlay: true })
} else if (payloadCache.age(period, provider) > STALE_MS) {
fetchKey(period, provider, { includeOptimize: true, showOverlay: false })
}
}, [period, provider, fetchKey])
useEffect(() => {
const unlistenRefresh = listen('codeburn://refresh', () => refreshAll({ includeOptimize: true, showOverlay: true }))
const unlistenShown = listen('codeburn://shown', () => {
const { period: p, provider: prov } = selection.current
if (payloadCache.age(p, prov) > STALE_MS) refreshAll({ includeOptimize: true, showOverlay: false })
})
const unlistenTheme = listen('codeburn://toggle-theme', () => toggleTheme())
return () => {
unlistenRefresh.then(fn => fn())
unlistenShown.then(fn => fn())
unlistenTheme.then(fn => fn())
}
}, [refreshAll])
useEffect(() => {
const saved = readSetting('theme')
if (saved === 'dark' || saved === 'light') applyTheme(saved)
setTheme(currentTheme())
const media = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => setTheme(currentTheme())
media.addEventListener('change', onChange)
return () => media.removeEventListener('change', onChange)
}, [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') invoke('hide_popover').catch(() => {})
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
useEffect(() => {
if (!todayPayload) return
const text = `CodeBurn · ${formatCurrency(todayPayload.current.cost, currency)} today`
invoke('set_tray_tooltip', { text }).catch(() => {})
}, [todayPayload, currency])
useEffect(() => {
const text = trayBadge && todayPayload ? trayBadgeText(todayPayload.current.cost, currency) : null
invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`))
}, [todayPayload, currency, trayBadge])
const chooseTheme = (choice: ThemeChoice) => {
applyTheme(choice === 'system' ? null : choice)
setThemeChoice(choice)
setTheme(currentTheme())
}
const toggleTheme = () => {
chooseTheme(currentTheme() === 'dark' ? 'light' : 'dark')
}
const setTrayBadgePref = (on: boolean) => {
setTrayBadge(on)
writeSetting('trayBadge', on ? 'on' : 'off')
}
const applyCurrency = async (code: string) => {
try {
setCurrency(await invoke<CurrencyState>('set_currency', { code }))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
const openTerminal = (args: string[]) => {
invoke('open_terminal_command', { args }).catch(err => setError(String(err)))
}
const connectClaude = () => {
invoke('open_claude_login').catch(err => setError(String(err)))
}
const selectInsight = (mode: InsightMode) => {
setInsight(mode)
writeSetting('insight', mode)
}
const providers = detectedProviders(todayPayload)
const planVisible = provider === 'claude' || (provider === 'all' && providers.length === 1 && providers[0] === 'claude')
const visibleModes = useMemo(
() => INSIGHT_ORDER.filter(m => m !== 'plan' || planVisible),
[planVisible],
)
const activeInsight = visibleModes.includes(insight) ? insight : 'trend'
const cliBlocked = cliStatus !== null && (!cliStatus.found || !cliStatus.compatible)
const isFilteredEmpty = payload !== null && provider !== 'all' && payload.current.cost <= 0 && payload.current.calls === 0
const neverAnyData = payload !== null && provider === 'all'
&& payload.current.calls === 0 && payload.current.sessions === 0 && payload.history.daily.length === 0
const footnote = [version ? `CodeBurn v${version}` : 'CodeBurn', lastUpdated ? `updated ${relativePast(lastUpdated)}` : null]
.filter(Boolean)
.join(' · ')
return (
<div className="popover">
<header className="header">
<div className="brand">
<span className="brand-primary">Code</span>
<span className="brand-accent">Burn</span>
</div>
<div className="subhead">AI Coding Cost Tracker</div>
</header>
{!cliBlocked && !showSettings && (
<AgentTabStrip selected={provider} onSelect={setProvider} payload={todayPayload} currency={currency} />
)}
<div className="main-content">
{showSettings ? (
<SettingsPanel
onBack={() => setShowSettings(false)}
version={version}
currency={currency}
onCurrency={applyCurrency}
themeChoice={themeChoice}
onThemeChoice={chooseTheme}
trayBadge={trayBadge}
onTrayBadge={setTrayBadgePref}
cliStatus={cliStatus}
onCheckCli={checkCli}
onProbeCli={probeCli}
cliChecking={cliChecking}
onQuit={() => invoke('quit_app').catch(() => {})}
/>
) : cliBlocked && cliStatus ? (
<SetupState status={cliStatus} checking={cliChecking} onCheckAgain={checkCli} />
) : (
<>
<HeroSection payload={payload} currency={currency} periodLabel={PERIOD_LABELS[period]} isToday={period === 'today'} />
<PeriodTabs selected={period} onSelect={setPeriod} />
{isFilteredEmpty ? (
<EmptyProviderState provider={provider} period={period} />
) : neverAnyData ? (
<NoDataState onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })} />
) : (
<>
<div className="insight-area">
<InsightPills selected={activeInsight} onSelect={selectInsight} modes={visibleModes} />
{activeInsight === 'plan' && (
<PlanInsight payload={payload} currency={currency} onOpenTerminal={openTerminal} onConnectClaude={connectClaude} />
)}
{activeInsight === 'trend' && <TrendInsight days={payload?.history.daily ?? []} currency={currency} />}
{activeInsight === 'forecast' && <ForecastInsight days={payload?.history.daily ?? []} currency={currency} />}
{activeInsight === 'pulse' && payload && <PulseInsight payload={payload} currency={currency} />}
{activeInsight === 'stats' && payload && <StatsInsight payload={payload} currency={currency} period={period} />}
</div>
{payload && (
<>
<ActivitySection payload={payload} currency={currency} />
<ModelsSection
models={payload.current.topModels}
inputTokens={payload.current.inputTokens}
outputTokens={payload.current.outputTokens}
cacheHitPercent={payload.current.cacheHitPercent}
currency={currency}
/>
<FindingsSection payload={payload} currency={currency} onOpenTerminal={openTerminal} />
</>
)}
</>
)}
{overlay && <LoadingOverlay periodLabel={PERIOD_LABELS[period]} />}
</>
)}
</div>
<FooterBar
currency={currency}
onCurrency={applyCurrency}
loading={overlay}
onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })}
onExport={format => openTerminal(['export', '-f', format])}
onOpenReport={() => openTerminal(['report'])}
onToggleTheme={toggleTheme}
onQuit={() => invoke('quit_app').catch(() => {})}
themeLabel={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
trayBadge={trayBadge}
onToggleTrayBadge={() => setTrayBadgePref(!trayBadge)}
onOpenSettings={() => setShowSettings(s => !s)}
settingsOpen={showSettings}
footnote={footnote}
/>
<StarBanner />
{error && <ErrorToast message={error} onDismiss={() => setError(null)} />}
</div>
)
}

View file

@ -0,0 +1,52 @@
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency } from '../lib/currency'
import { CollapsibleSection } from './CollapsibleSection'
/// Column widths shared with the header captions (mac: Cost 54 / Turns 52 / 1-shot 44).
export const COL_COST = 54
export const COL_COUNT = 52
export const COL_ONESHOT = 44
type Props = {
payload: MenubarPayload
currency: CurrencyState
}
export function ActivitySection({ payload, currency }: Props) {
const activities = payload.current.topActivities
if (activities.length === 0) return null
const maxCost = Math.max(...activities.map(a => a.cost), 0.01)
return (
<CollapsibleSection
caption="Activity"
columns={[
{ label: 'Cost', width: COL_COST },
{ label: 'Turns', width: COL_COUNT },
{ label: '1-shot', width: COL_ONESHOT },
]}
>
{activities.map(a => (
<div key={a.name} className="data-row">
<FixedBar fraction={a.cost / maxCost} />
<span className="row-name">{a.name}</span>
<span className="row-cost" style={{ minWidth: COL_COST }}>{formatCompactCurrency(a.cost, currency)}</span>
<span className="row-count" style={{ minWidth: COL_COUNT }}>{a.turns}</span>
<span className="row-oneshot" style={{ minWidth: COL_ONESHOT }}>
{a.oneShotRate == null ? '-' : `${Math.round(a.oneShotRate * 100)}%`}
</span>
</div>
))}
</CollapsibleSection>
)
}
export function FixedBar({ fraction }: { fraction: number }) {
const pct = Math.min(Math.max(fraction, 0), 1) * 100
return (
<span className="fixed-bar" aria-hidden="true">
<span className="fixed-bar-fill" style={{ width: `${pct}%` }} />
</span>
)
}

View file

@ -0,0 +1,116 @@
import { useRef, useState, type WheelEvent } from 'react'
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency, formatCurrency, plural } from '../lib/currency'
import { homePath } from '../lib/platform'
export type Provider = 'all' | 'claude' | 'codex' | 'cursor' | 'copilot' | 'opencode' | 'pi'
/// Same order as the macOS ProviderFilter.allCases.
export const ALL_PROVIDERS: Array<{ id: Provider; label: string; source: string }> = [
{ id: 'all', label: 'All', source: 'every detected tool' },
{ id: 'claude', label: 'Claude', source: `Claude Code sessions in ${homePath('.claude', 'projects')}` },
{ id: 'codex', label: 'Codex', source: `Codex CLI sessions in ${homePath('.codex', 'sessions')}` },
{ id: 'cursor', label: 'Cursor', source: 'the Cursor IDE local database' },
{ id: 'copilot', label: 'Copilot', source: 'GitHub Copilot session events' },
{ id: 'opencode', label: 'OpenCode', source: 'OpenCode session storage' },
{ id: 'pi', label: 'Pi', source: 'Pi session logs' },
]
export const PROVIDER_LABELS: Record<Provider, string> = Object.fromEntries(
ALL_PROVIDERS.map(p => [p.id, p.label]),
) as Record<Provider, string>
/// Providers the CLI detected on this machine (installed, even with zero spend today).
export function detectedProviders(payload: MenubarPayload | null): Provider[] {
if (!payload) return []
const detected = payload.current.providers
return ALL_PROVIDERS.map(p => p.id).filter(id => id !== 'all' && id in detected)
}
type Props = {
selected: Provider
onSelect: (p: Provider) => void
payload: MenubarPayload | null
currency: CurrencyState
}
/// Every supported tool is listed so the reader can see at a glance which ones CodeBurn
/// is watching. Tools that are not installed are dimmed and explain themselves on hover;
/// detected tools show today's spend and a hover preview with their share.
export function AgentTabStrip({ selected, onSelect, payload, currency }: Props) {
const [hovered, setHovered] = useState<Provider | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const providers = detectedProviders(payload)
const costs = payload?.current.providers ?? {}
const total = providers.reduce((s, id) => s + (costs[id] ?? 0), 0)
const onWheel = (e: WheelEvent<HTMLDivElement>) => {
if (scroller.current && e.deltaY !== 0 && e.deltaX === 0) {
scroller.current.scrollLeft += e.deltaY
}
}
const preview = hovered ? previewFor(hovered, providers, costs, total, currency) : null
return (
<div className="agent-tabs-wrap" onMouseLeave={() => setHovered(null)}>
<nav className="agent-tabs" aria-label="Provider" ref={scroller} onWheel={onWheel}>
{ALL_PROVIDERS.map(p => {
const detected = p.id === 'all' ? providers.length > 0 : providers.includes(p.id)
const cost = p.id === 'all' ? total : (costs[p.id] ?? 0)
const active = selected === p.id
return (
<button
key={p.id}
type="button"
className={`tab ${active ? 'tab-active' : ''} ${detected ? '' : 'tab-muted'}`}
aria-pressed={active}
aria-disabled={!detected}
onMouseEnter={() => setHovered(p.id)}
onFocus={() => setHovered(p.id)}
onClick={() => { if (detected) onSelect(p.id) }}
>
<span className="tab-label">{p.label}</span>
{detected && cost > 0 && (
<span className="tab-cost">{formatCompactCurrency(cost, currency)}</span>
)}
</button>
)
})}
</nav>
{preview && (
<div className="tab-preview" role="tooltip">
<div className="tab-preview-title">{preview.title}</div>
<div className="tab-preview-body">{preview.body}</div>
</div>
)}
</div>
)
}
function previewFor(
id: Provider,
providers: Provider[],
costs: Record<string, number>,
total: number,
currency: CurrencyState,
): { title: string; body: string } {
const meta = ALL_PROVIDERS.find(p => p.id === id)!
if (id === 'all') {
if (providers.length === 0) return { title: 'No tools detected yet', body: 'Run one of the supported tools once, then refresh.' }
return {
title: `${formatCurrency(total, currency)} today across ${plural(providers.length, 'tool')}`,
body: providers.map(p => `${PROVIDER_LABELS[p]} ${formatCompactCurrency(costs[p] ?? 0, currency)}`).join(' · '),
}
}
if (!providers.includes(id)) {
return { title: `${meta.label} not detected on this machine`, body: `CodeBurn watches ${meta.source}.` }
}
const cost = costs[id] ?? 0
const share = total > 0 ? Math.round((cost / total) * 100) : 0
return {
title: `${meta.label} · ${formatCurrency(cost, currency)} today`,
body: cost > 0 ? `${share}% of today's spend · click to filter every view` : 'No spend yet today · click to filter every view',
}
}

View file

@ -0,0 +1,44 @@
import { useState, type ReactNode } from 'react'
import { ChevronRight } from './Icons'
/// The macOS CollapsibleSection shell: 3px brand dot + caption, trailing column headers,
/// a chevron that rotates 90 degrees when open. Same component for Activity and Models so
/// the two headers can never drift apart again.
type Props = {
caption: string
columns?: Array<{ label: string; width: number }>
defaultExpanded?: boolean
children: ReactNode
}
export function CollapsibleSection({ caption, columns = [], defaultExpanded = true, children }: Props) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<section className="collapsible">
<button
type="button"
className="collapsible-header"
aria-expanded={expanded}
onClick={() => setExpanded(e => !e)}
>
<SectionCaption text={caption} />
<span className="collapsible-spacer" />
{expanded && columns.map(c => (
<span key={c.label} className="col-header" style={{ minWidth: c.width }}>{c.label}</span>
))}
<ChevronRight size={9} className={`chevron ${expanded ? 'chevron-open' : ''}`} />
</button>
{expanded && <div className="collapsible-body">{children}</div>}
</section>
)
}
export function SectionCaption({ text, muted = true }: { text: string; muted?: boolean }) {
return (
<span className={`section-caption ${muted ? '' : 'section-caption-strong'}`}>
<span className="section-dot" />
{text}
</span>
)
}

View file

@ -0,0 +1,93 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { CheckIcon } from './Icons'
/// A bordered footer button that opens a small menu above itself, standing in for the
/// macOS `Menu` with `.bordered` style. Closes on outside click, Escape, or selection.
export type MenuItem = {
id: string
label: string
checked?: boolean
disabled?: boolean
danger?: boolean
separatorBefore?: boolean
}
type Props = {
label: ReactNode
title?: string
items: MenuItem[]
onSelect: (id: string) => void
align?: 'left' | 'right'
className?: string
/// Optional read-only footer line under the items (version, last update).
footnote?: string
/// Lay the items out in a grid (used for the 17-currency picker) instead of one column.
columns?: number
}
export function DropMenu({ label, title, items, onSelect, align = 'left', className = '', footnote, columns = 1 }: Props) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
setOpen(false)
}
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
return (
<div className={`dropmenu ${className}`} ref={rootRef}>
<button
type="button"
className={`btn ${open ? 'btn-pressed' : ''}`}
title={title}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(o => !o)}
>
{label}
</button>
{open && (
<div
className={`dropmenu-panel dropmenu-${align} ${columns > 1 ? 'dropmenu-grid' : ''}`}
role="menu"
style={columns > 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
>
{items.map(item => (
<div key={item.id} className="dropmenu-item-wrap">
{item.separatorBefore && <div className="dropmenu-sep" />}
<button
type="button"
role="menuitem"
className={`dropmenu-item ${item.danger ? 'dropmenu-danger' : ''}`}
disabled={item.disabled}
onClick={() => {
setOpen(false)
onSelect(item.id)
}}
>
<span className="dropmenu-check">{item.checked && <CheckIcon size={11} />}</span>
<span className="dropmenu-label">{item.label}</span>
</button>
</div>
))}
{footnote && <div className="dropmenu-footnote">{footnote}</div>}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,19 @@
import type { Provider } from './AgentTabStrip'
import { PROVIDER_LABELS } from './AgentTabStrip'
import type { Period } from './PeriodTabs'
import { PERIOD_PHRASES } from './PeriodTabs'
import { TrayIcon } from './Icons'
type Props = {
provider: Provider
period: Period
}
export function EmptyProviderState({ provider, period }: Props) {
return (
<div className="empty-provider">
<TrayIcon size={26} className="empty-provider-icon" />
<div className="empty-provider-text">No {PROVIDER_LABELS[provider]} data for {PERIOD_PHRASES[period]}</div>
</div>
)
}

View file

@ -0,0 +1,29 @@
import { useEffect, useRef } from 'react'
import { XIcon } from './Icons'
const AUTO_DISMISS_MS = 8_000
type Props = {
message: string
onDismiss: () => void
}
export function ErrorToast({ message, onDismiss }: Props) {
// Keep the latest handler in a ref so re-renders of the parent do not restart the timer.
const dismissRef = useRef(onDismiss)
dismissRef.current = onDismiss
useEffect(() => {
const id = setTimeout(() => dismissRef.current(), AUTO_DISMISS_MS)
return () => clearTimeout(id)
}, [message])
return (
<div className="error-toast" role="alert">
<span className="error-toast-text">{message}</span>
<button type="button" className="error-toast-close" onClick={onDismiss} aria-label="Dismiss">
<XIcon size={9} />
</button>
</div>
)
}

View file

@ -0,0 +1,72 @@
import { useState } from 'react'
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { computeTipGroups, type TipGroup } from '../lib/tips'
import { plural } from '../lib/currency'
import { ArrowForward, ArrowUpRightCircleIcon, BulbIcon, CheckCircleIcon, ChevronRight, WarningIcon } from './Icons'
type Props = {
payload: MenubarPayload
currency: CurrencyState
onOpenTerminal: (args: string[]) => void
}
export function FindingsSection({ payload, currency, onOpenTerminal }: Props) {
const [expanded, setExpanded] = useState(true)
const groups = computeTipGroups(payload, currency)
const totalSignals = groups.reduce((s, g) => s + g.items.length, 0)
if (totalSignals === 0) return null
return (
<section className="findings-wrap">
<div className="findings-card">
<button type="button" className="findings-header" aria-expanded={expanded} onClick={() => setExpanded(e => !e)}>
<span className="findings-header-left">
<BulbIcon size={11} className="findings-icon" />
<span className="findings-title">Tips for you</span>
</span>
<span className="findings-header-right">
<span className="findings-count">{plural(totalSignals, 'signal')}</span>
<ChevronRight size={9} className={`chevron ${expanded ? 'chevron-open' : ''}`} />
</span>
</button>
{expanded && (
<div className="findings-body">
{groups.map(g => g.items.length > 0 && <TipsGroupView key={g.label} group={g} />)}
{payload.optimize.findingCount > 0 && (
<button type="button" className="findings-open-optimize" onClick={() => onOpenTerminal(['optimize'])}>
<span>Open Full Optimize</span>
<ArrowForward size={9} />
</button>
)}
</div>
)}
</div>
</section>
)
}
function GroupIcon({ icon }: { icon: string }) {
if (icon === 'check') return <CheckCircleIcon size={10} />
if (icon === 'up') return <ArrowUpRightCircleIcon size={10} />
return <WarningIcon size={10} />
}
function TipsGroupView({ group }: { group: TipGroup }) {
return (
<div className="tips-group">
<div className="tips-group-header">
<GroupIcon icon={group.icon} />
<span>{group.label}</span>
</div>
{group.items.map((item, i) => (
<div key={i} className="tips-item">
<span className="tips-bullet" />
<span className="tips-text">{item.text}</span>
{item.trailing && <span className="tips-trailing">{item.trailing}</span>}
</div>
))}
</div>
)
}

View file

@ -0,0 +1,81 @@
import type { CurrencyState } from '../lib/currency'
import { CURRENCY_CODES } from '../lib/currency'
import { DropMenu } from './DropMenu'
import { CoinIcon, DownloadIcon, EllipsisIcon, RefreshIcon, TerminalIcon } from './Icons'
type Props = {
currency: CurrencyState
onCurrency: (code: string) => void
loading: boolean
onRefresh: () => void
onExport: (format: 'csv' | 'json') => void
onOpenReport: () => void
onToggleTheme: () => void
onQuit: () => void
themeLabel: string
footnote: string
trayBadge: boolean
onToggleTrayBadge: () => void
onOpenSettings: () => void
settingsOpen: boolean
}
export function FooterBar({
currency, onCurrency, loading, onRefresh, onExport, onOpenReport, onToggleTheme, onQuit, themeLabel, footnote,
trayBadge, onToggleTrayBadge, onOpenSettings, settingsOpen,
}: Props) {
return (
<footer className="footer">
<DropMenu
title="Currency"
label={<><CoinIcon size={12} /><span>{currency.code}</span></>}
items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))}
columns={3}
onSelect={onCurrency}
/>
<button
type="button"
className={`btn btn-icon ${loading ? 'btn-spinning' : ''}`}
title="Refresh"
aria-label="Refresh"
onClick={onRefresh}
disabled={loading}
>
<RefreshIcon size={12} />
</button>
<DropMenu
title="Export"
label={<><DownloadIcon size={12} /><span>Export</span></>}
items={[
{ id: 'csv', label: 'CSV (folder)' },
{ id: 'json', label: 'JSON' },
]}
onSelect={id => onExport(id as 'csv' | 'json')}
/>
<span className="footer-spacer" />
<button type="button" className="btn btn-prominent" onClick={onOpenReport}>
<TerminalIcon size={12} />
<span>Open Full Report</span>
</button>
<DropMenu
title="More"
align="right"
label={<EllipsisIcon size={12} />}
className="dropmenu-more"
items={[
{ id: 'settings', label: settingsOpen ? 'Back to overview' : 'Settings…' },
{ id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true },
{ id: 'theme', label: themeLabel },
{ id: 'quit', label: 'Quit CodeBurn', separatorBefore: true },
]}
footnote={footnote}
onSelect={id => {
if (id === 'settings') onOpenSettings()
if (id === 'badge') onToggleTrayBadge()
if (id === 'theme') onToggleTheme()
if (id === 'quit') onQuit()
}}
/>
</footer>
)
}

View file

@ -0,0 +1,59 @@
import type { DailyEntry } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCurrency, formatCompactCurrency } from '../lib/currency'
import { computeHistoryStats } from '../lib/history'
import { ArrowUpRight, ArrowDownRight } from './Icons'
const WEEK_DAYS = 7
type Props = {
days: DailyEntry[]
currency: CurrencyState
}
export function ForecastInsight({ days, currency }: Props) {
const s = computeHistoryStats(days)
const prevDelta = s.previousMonthTotal && s.previousMonthTotal > 0
? ((s.monthProjection - s.previousMonthTotal) / s.previousMonthTotal) * 100
: null
return (
<div className="forecast-insight">
<div className="insight-header">
<div>
<div className="insight-sublabel">Month-to-date</div>
<div className="forecast-mtd">{formatCurrency(s.monthToDate, currency)}</div>
</div>
<div className="forecast-right">
<div className="insight-sublabel">On pace for</div>
<div className="forecast-projection">{formatCurrency(s.monthProjection, currency)}</div>
</div>
</div>
<div className="mini-stats">
<div className="mini-stat">
<div className="mini-stat-label">Avg/day (this wk)</div>
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal / WEEK_DAYS, currency)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Yesterday</div>
<div className="mini-stat-value">{formatCompactCurrency(s.yesterday, currency)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Last 7d</div>
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal, currency)}</div>
</div>
</div>
{prevDelta !== null && s.previousMonthTotal !== null && (
<div className="delta-badge delta-badge-block">
{prevDelta >= 0 ? <ArrowUpRight size={9} /> : <ArrowDownRight size={9} />}
<span>
{prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month
({formatCompactCurrency(s.previousMonthTotal, currency)})
</span>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCurrency, plural } from '../lib/currency'
import { prettyDate, todayKey } from '../lib/dates'
import { SectionCaption } from './CollapsibleSection'
type Props = {
payload: MenubarPayload | null
currency: CurrencyState
periodLabel: string
isToday: boolean
}
export function HeroSection({ payload, currency, periodLabel, isToday }: Props) {
const todayLabel = prettyDate(todayKey())
const caption = isToday ? `Today · ${todayLabel}` : (payload?.current.label || periodLabel)
return (
<section className="hero">
<SectionCaption text={caption} />
<div className="hero-row">
{payload ? (
<div className="hero-amount">{formatCurrency(payload.current.cost, currency)}</div>
) : (
<div className="hero-amount hero-skeleton" aria-label="Loading" />
)}
<div className="hero-meta">
{payload ? (
<>
<span className="hero-calls">{payload.current.calls.toLocaleString()} {payload.current.calls === 1 ? 'call' : 'calls'}</span>
<span className="hero-sessions">{plural(payload.current.sessions, 'session')}</span>
</>
) : (
<>
<span className="hero-skeleton-line" />
<span className="hero-skeleton-line short" />
</>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,195 @@
import type { SVGProps } from 'react'
/// Small stroke icons standing in for the SF Symbols the macOS app uses. All are drawn on
/// a 16x16 grid at 1.5px stroke so they sit on the same optical baseline as 11px text.
type IconProps = SVGProps<SVGSVGElement> & { size?: number }
function Svg({ size = 12, children, ...rest }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...rest}
>
{children}
</svg>
)
}
export const FLAME_PATH = 'M8 1.5c.4 2.2 1.9 3.3 3.1 4.6C12.4 7.5 13 8.9 13 10.3 13 13 10.8 15 8 15s-5-2-5-4.7c0-1.6.7-2.8 1.5-3.7.2 1 .8 1.8 1.6 2.2C6 7 6.6 4.5 8 1.5z'
export function FlameIcon({ filled = false, ...p }: IconProps & { filled?: boolean }) {
return <Svg {...p}><path d={FLAME_PATH} fill={filled ? 'currentColor' : 'none'} /></Svg>
}
export function ChevronRight(p: IconProps) {
return <Svg {...p}><path d="M6 3.5 10.5 8 6 12.5" /></Svg>
}
export function ChevronDown(p: IconProps) {
return <Svg {...p}><path d="M3.5 6 8 10.5 12.5 6" /></Svg>
}
export function RefreshIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2.5v3h-3" />
</Svg>
)
}
export function DownloadIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="M8 2.5v8M5 7.5l3 3 3-3" />
<path d="M3 11.5v1.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-1.5" />
</Svg>
)
}
export function TerminalIcon(p: IconProps) {
return (
<Svg {...p}>
<rect x="2" y="3" width="12" height="10" rx="1.5" />
<path d="M4.5 6.5 6.5 8l-2 1.5M8 10h3" />
</Svg>
)
}
export function CoinIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="6" />
<path d="M8 4.5v7M10 6.2c-.3-.7-1-1-2-1s-2 .5-2 1.3c0 1.9 4 .9 4 2.9 0 .8-1 1.3-2 1.3s-1.8-.4-2.1-1" />
</Svg>
)
}
export function StarIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="m8 1.8 1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z" fill="currentColor" stroke="none" />
</Svg>
)
}
export function XIcon(p: IconProps) {
return <Svg {...p}><path d="M4 4l8 8M12 4l-8 8" /></Svg>
}
export function BulbIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="M5.5 11.5a4.5 4.5 0 1 1 5 0v1.5h-5z" fill="currentColor" stroke="none" />
<path d="M6.5 14.5h3" />
</Svg>
)
}
export function CheckCircleIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="6" fill="currentColor" stroke="none" />
<path d="M5.3 8.2 7.2 10l3.5-4" stroke="var(--icon-contrast, #fff)" />
</Svg>
)
}
export function ArrowUpRightCircleIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="6" fill="currentColor" stroke="none" />
<path d="M6 10l4-4M6.5 6H10v3.5" stroke="var(--icon-contrast, #fff)" />
</Svg>
)
}
export function WarningIcon({ filled = true, ...p }: IconProps & { filled?: boolean }) {
return (
<Svg {...p}>
<path d="M8 2.2 14.3 13H1.7z" fill={filled ? 'currentColor' : 'none'} />
<path d="M8 6.2v3.3M8 11.3v.2" stroke={filled ? 'var(--icon-contrast, #fff)' : 'currentColor'} />
</Svg>
)
}
export function ArrowUpRight(p: IconProps) {
return <Svg {...p}><path d="M4.5 11.5 11.5 4.5M6 4.5h5.5V10" /></Svg>
}
export function ArrowDownRight(p: IconProps) {
return <Svg {...p}><path d="M4.5 4.5 11.5 11.5M6 11.5h5.5V6" /></Svg>
}
export function ArrowForward(p: IconProps) {
return <Svg {...p}><path d="M3 8h10M9 4l4 4-4 4" /></Svg>
}
export function KeySlashIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="5.5" cy="6.5" r="3" />
<path d="M8 8.5 13.5 14M11 11.5l1.5-1.5M2 14 14 2" />
</Svg>
)
}
export function PersonDashedIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="6.5" strokeDasharray="2.2 2" />
<circle cx="8" cy="6.5" r="2" />
<path d="M4.8 12.2c.6-1.6 1.8-2.4 3.2-2.4s2.6.8 3.2 2.4" />
</Svg>
)
}
export function TrayIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="M2.5 9.5h3.2l.8 1.5h3l.8-1.5h3.2" />
<path d="M2.5 9.5 4 4.5h8l1.5 5v3a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z" />
</Svg>
)
}
export function EllipsisIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="3.5" cy="8" r="1.2" fill="currentColor" stroke="none" />
<circle cx="8" cy="8" r="1.2" fill="currentColor" stroke="none" />
<circle cx="12.5" cy="8" r="1.2" fill="currentColor" stroke="none" />
</Svg>
)
}
export function CheckIcon(p: IconProps) {
return <Svg {...p}><path d="M3.5 8.5 6.5 11.5 12.5 5" /></Svg>
}
export function SunMoonIcon(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="4" />
<path d="M8 4V1.5M8 14.5V12M4 8H1.5M14.5 8H12M5.2 5.2 3.4 3.4M12.6 12.6l-1.8-1.8M5.2 10.8l-1.8 1.8M12.6 3.4l-1.8 1.8" />
</Svg>
)
}
export function PowerIcon(p: IconProps) {
return (
<Svg {...p}>
<path d="M8 2v6" />
<path d="M4.6 4.6a5 5 0 1 0 6.8 0" />
</Svg>
)
}

View file

@ -0,0 +1,41 @@
export type InsightMode = 'plan' | 'trend' | 'forecast' | 'pulse' | 'stats'
export const INSIGHT_LABELS: Record<InsightMode, string> = {
plan: 'Plan',
trend: 'Trend',
forecast: 'Forecast',
pulse: 'Pulse',
stats: 'Stats',
}
/// Same order as the macOS InsightMode enum: Plan first when it is visible.
export const INSIGHT_ORDER: InsightMode[] = ['plan', 'trend', 'forecast', 'pulse', 'stats']
export function isInsightMode(value: string | null): value is InsightMode {
return value !== null && value in INSIGHT_LABELS
}
type Props = {
selected: InsightMode
onSelect: (m: InsightMode) => void
modes: InsightMode[]
}
export function InsightPills({ selected, onSelect, modes }: Props) {
return (
<div className="insight-pills" role="tablist">
{modes.map(m => (
<button
key={m}
type="button"
role="tab"
aria-selected={selected === m}
className={`insight-pill ${selected === m ? 'insight-pill-active' : ''}`}
onClick={() => onSelect(m)}
>
{INSIGHT_LABELS[m]}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,44 @@
import { FLAME_PATH } from './Icons'
/// The macOS BurnLoadingOverlay: a blurred sheet over the scroll area with a flame that
/// fills bottom-to-top on a 1.4s loop while a soft glow pulses behind it.
type Props = { periodLabel: string }
export function LoadingOverlay({ periodLabel }: Props) {
return (
<div className="loading-overlay" role="status" aria-live="polite">
<div className="loading-content">
<BurnFlame />
<div className="loading-text">Loading {periodLabel}</div>
</div>
</div>
)
}
export function BurnFlame({ size = 64 }: { size?: number }) {
return (
<div className="burn-flame" style={{ width: size, height: size }}>
<svg className="burn-flame-glow" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
<path d={FLAME_PATH} />
</svg>
<svg className="burn-flame-outline" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
<path d={FLAME_PATH} />
</svg>
<svg className="burn-flame-fill" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
<defs>
<linearGradient id="burn-gradient" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#F0A070" />
<stop offset="33.33%" stopColor="#E8774A" />
<stop offset="66.66%" stopColor="#C9521D" />
<stop offset="100%" stopColor="#8B3E13" />
</linearGradient>
<clipPath id="burn-clip">
<rect className="burn-clip-rect" x="0" y="0" width="16" height="16" />
</clipPath>
</defs>
<path d={FLAME_PATH} fill="url(#burn-gradient)" clipPath="url(#burn-clip)" />
</svg>
</div>
)
}

View file

@ -0,0 +1,47 @@
import type { Model } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency, formatTokens } from '../lib/currency'
import { CollapsibleSection } from './CollapsibleSection'
import { FixedBar, COL_COST, COL_COUNT } from './ActivitySection'
type Props = {
models: Model[]
inputTokens: number
outputTokens: number
cacheHitPercent: number
currency: CurrencyState
}
export function ModelsSection({ models, inputTokens, outputTokens, cacheHitPercent, currency }: Props) {
if (models.length === 0) return null
const maxCost = Math.max(...models.map(m => m.cost), 0.01)
return (
<CollapsibleSection
caption="Models"
columns={[
{ label: 'Cost', width: COL_COST },
{ label: 'Calls', width: COL_COUNT },
]}
>
{models.map(m => (
<div key={m.name} className="data-row">
<FixedBar fraction={m.cost / maxCost} />
<span className="row-name">{m.name}</span>
<span className="row-cost" style={{ minWidth: COL_COST }}>{formatCompactCurrency(m.cost, currency)}</span>
<span className="row-count" style={{ minWidth: COL_COUNT }}>{m.calls}</span>
</div>
))}
{(inputTokens > 0 || outputTokens > 0) && (
<div className="tokens-line">
<span className="tokens-label">Tokens</span>
<span className="tokens-value">{formatTokens(inputTokens)} in</span>
<span className="tokens-sep">·</span>
<span className="tokens-value">{formatTokens(outputTokens)} out</span>
<span className="tokens-sep">·</span>
<span className="tokens-value">{Math.round(cacheHitPercent)}% cache hit</span>
</div>
)}
</CollapsibleSection>
)
}

View file

@ -0,0 +1,35 @@
/// First-run copy for a machine where the CLI ran fine but found no sessions. Paths are
/// shown the way the reader's own OS spells them.
import { homePath } from '../lib/platform'
const SOURCES: Array<{ path: string | null; tool: string }> = [
{ path: homePath('.claude', 'projects'), tool: 'Claude Code' },
{ path: homePath('.codex', 'sessions'), tool: 'Codex CLI' },
{ path: null, tool: 'Cursor local database' },
{ path: null, tool: 'GitHub Copilot session events' },
{ path: homePath('.local', 'share', 'opencode'), tool: 'OpenCode' },
{ path: homePath('.pi'), tool: 'Pi' },
]
export function NoDataState({ onRefresh }: { onRefresh: () => void }) {
return (
<section className="no-data">
<h2 className="no-data-title">No session data yet</h2>
<p>
CodeBurn reads local session logs written by your AI coding tools. None of the
supported tools have recorded a session on this machine yet.
</p>
<p className="no-data-sub">Watched locations</p>
<ul>
{SOURCES.map(s => (
<li key={s.tool}>
{s.path ? <><code>{s.path}</code> <span className="no-data-tool">{s.tool}</span></> : s.tool}
</li>
))}
</ul>
<p>Run one of those tools for a session, then refresh.</p>
<button type="button" className="btn" onClick={onRefresh}>Refresh now</button>
</section>
)
}

View file

@ -0,0 +1,41 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export const PERIOD_LABELS: Record<Period, string> = {
today: 'Today', week: '7 Days', '30days': '30 Days', month: 'Month', all: 'All',
}
/// Short phrase used in sentences ("Sessions (7 days)", "No Claude data for this month").
export const PERIOD_PHRASES: Record<Period, string> = {
today: 'today',
week: 'the last 7 days',
'30days': 'the last 30 days',
month: 'this month',
all: 'all time',
}
const PERIODS = Object.keys(PERIOD_LABELS) as Period[]
type Props = {
selected: Period
onSelect: (p: Period) => void
}
export function PeriodTabs({ selected, onSelect }: Props) {
return (
<div className="period-wrap">
<nav className="period-tabs" aria-label="Period">
{PERIODS.map(p => (
<button
key={p}
type="button"
className={`period ${selected === p ? 'period-active' : ''}`}
aria-pressed={selected === p}
onClick={() => onSelect(p)}
>
{PERIOD_LABELS[p]}
</button>
))}
</nav>
</div>
)
}

View file

@ -0,0 +1,147 @@
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency, formatTokens, plural } from '../lib/currency'
import { relativeFuture } from '../lib/dates'
import type { PlanUsage, PlanWindow } from '../lib/plan'
import { projectWindow, earliestReset } from '../lib/plan'
import { BulbIcon, ChevronRight, KeySlashIcon, PersonDashedIcon, WarningIcon, ArrowUpRight } from './Icons'
/// Sonnet-weighted approximation the mac app uses to turn a dollar saving into tokens.
const USD_PER_MILLION_EFFECTIVE_TOKENS = 9
const MILLION = 1_000_000
const PLAN_REFRESH_MS = 5 * 60_000
type LoadState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'loaded'; usage: Extract<PlanUsage, { state: 'ok' }> }
| { kind: 'no_credentials' }
| { kind: 'failed'; message: string }
type Props = {
payload: MenubarPayload | null
currency: CurrencyState
onOpenTerminal: (args: string[]) => void
onConnectClaude: () => void
}
export function PlanInsight({ payload, currency, onOpenTerminal, onConnectClaude }: Props) {
const [state, setState] = useState<LoadState>({ kind: 'idle' })
const [now, setNow] = useState(() => new Date())
const load = async () => {
setState(prev => (prev.kind === 'loaded' ? prev : { kind: 'loading' }))
try {
const usage = await invoke<PlanUsage>('plan_usage')
if (usage.state === 'ok') setState({ kind: 'loaded', usage })
else if (usage.state === 'no_credentials') setState({ kind: 'no_credentials' })
else setState({ kind: 'failed', message: usage.message })
} catch (err) {
setState({ kind: 'failed', message: err instanceof Error ? err.message : String(err) })
}
setNow(new Date())
}
useEffect(() => {
load()
const id = setInterval(load, PLAN_REFRESH_MS)
return () => clearInterval(id)
}, [])
switch (state.kind) {
case 'idle':
case 'loading':
return (
<div className="plan-state">
<PersonDashedIcon size={22} className="plan-state-icon" />
<div className="plan-state-title-muted">Loading your plan...</div>
<div className="plan-state-note">Reading Claude Code credentials from this machine.</div>
</div>
)
case 'no_credentials':
return (
<div className="plan-state">
<KeySlashIcon size={20} className="plan-state-icon" />
<div className="plan-state-title">No Claude subscription connected</div>
<div className="plan-state-note">Click Connect to sign in with Claude in a terminal, then return here.</div>
<div className="plan-actions">
<button type="button" className="btn btn-prominent" onClick={() => onConnectClaude()}>Connect Claude</button>
<button type="button" className="btn" onClick={load}>Retry</button>
</div>
</div>
)
case 'failed':
return (
<div className="plan-state">
<WarningIcon size={18} filled={false} className="plan-state-icon plan-state-icon-accent" />
<div className="plan-state-title">Couldn't load plan data</div>
<div className="plan-state-error">{state.message}</div>
<div className="plan-actions">
<button type="button" className="btn btn-prominent" onClick={() => onConnectClaude()}>Reconnect Claude</button>
<button type="button" className="btn" onClick={load}>Retry</button>
</div>
</div>
)
case 'loaded': {
const { usage } = state
const reset = earliestReset(usage.windows)
return (
<div className="plan-insight">
<div className="plan-header">
<span className="plan-tier">{usage.tier}</span>
{reset && <span className="plan-reset">Resets {relativeFuture(reset, now)}</span>}
</div>
<div className="plan-rows">
{usage.windows.map(w => <UtilizationRow key={w.key} window={w} now={now} />)}
</div>
{payload && payload.optimize.findingCount > 0 && payload.optimize.savingsUSD > 0 && (
<button type="button" className="savings-badge" onClick={() => onOpenTerminal(['optimize'])}>
<BulbIcon size={10} className="savings-badge-icon" />
<span>
Save ~{formatCompactCurrency(payload.optimize.savingsUSD, currency)} / ~
{formatTokens((payload.optimize.savingsUSD / USD_PER_MILLION_EFFECTIVE_TOKENS) * MILLION)} tokens
{' · '}{plural(payload.optimize.findingCount, 'finding')}
</span>
<ChevronRight size={8} className="savings-badge-chevron" />
</button>
)}
</div>
)
}
}
}
function UtilizationRow({ window, now }: { window: PlanWindow; now: Date }) {
const projection = projectWindow(window, now)
const clamped = Math.min(Math.max(window.percent, 0), 100)
const marker = projection ? Math.min(Math.max(projection.percent, 0), 100) : null
let caption: string | null = null
if (projection) {
const pct = Math.round(projection.percent)
if (projection.source === 'historical') caption = `Based on last cycle: ${pct}%`
else if (projection.willOverflow && projection.hitsLimitAt) caption = `On pace: ${pct}% at reset · hits 100% ${relativeFuture(projection.hitsLimitAt, now)}`
else caption = `On pace: ${pct}% at reset`
}
return (
<div className="util-row">
<div className="util-row-head">
<span className="util-label">{window.label}</span>
<span className="util-percent">{Math.round(clamped)}%</span>
</div>
<div className="util-bar">
<div className="util-bar-fill" style={{ width: `${clamped}%` }} />
{marker !== null && <div className="util-bar-marker" style={{ left: `calc(${marker}% - 0.75px)` }} />}
</div>
{caption && (
<div className={`util-caption ${projection?.willOverflow ? 'util-caption-warn' : ''}`}>
{projection?.willOverflow ? <WarningIcon size={8} /> : <ArrowUpRight size={8} />}
<span>{caption}</span>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,32 @@
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency } from '../lib/currency'
type Props = {
payload: MenubarPayload
currency: CurrencyState
}
export function PulseInsight({ payload, currency }: Props) {
const { cacheHitPercent, oneShotRate, cost, sessions } = payload.current
const cacheText = cacheHitPercent <= 0 ? '-' : `${Math.round(cacheHitPercent)}%`
const oneShotText = oneShotRate == null ? '-' : `${Math.round(oneShotRate * 100)}%`
const costPerSession = sessions > 0 ? formatCompactCurrency(cost / sessions, currency) : '-'
return (
<div className="pulse-tiles">
<div className="pulse-tile">
<div className="pulse-label">Cache hit</div>
<div className="pulse-value pulse-value-accent">{cacheText}</div>
</div>
<div className="pulse-tile">
<div className="pulse-label">1-shot</div>
<div className={`pulse-value ${oneShotRate == null ? '' : 'pulse-value-accent'}`}>{oneShotText}</div>
</div>
<div className="pulse-tile">
<div className="pulse-label">Cost / session</div>
<div className="pulse-value">{costPerSession}</div>
</div>
</div>
)
}

View file

@ -0,0 +1,156 @@
import { useEffect, useState, type ReactNode } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { CurrencyState } from '../lib/currency'
import { CURRENCY_CODES } from '../lib/currency'
import { homePath } from '../lib/platform'
import type { CliStatus } from './SetupState'
import { DropMenu } from './DropMenu'
import { ChevronDown, ChevronRight } from './Icons'
/// Preferences that have no home in the popover proper. Deliberately small: the mac app has
/// no settings window at all, so everything here is a Windows/Linux need (login item, tray
/// text) or a convenience the footer already offers in a smaller form.
export type ThemeChoice = 'system' | 'light' | 'dark'
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
type Props = {
onBack: () => void
version: string
currency: CurrencyState
onCurrency: (code: string) => void
themeChoice: ThemeChoice
onThemeChoice: (t: ThemeChoice) => void
trayBadge: boolean
onTrayBadge: (on: boolean) => void
cliStatus: CliStatus | null
onCheckCli: () => void
onProbeCli: () => void
cliChecking: boolean
onQuit: () => void
}
export function SettingsPanel({
onBack, version, currency, onCurrency, themeChoice, onThemeChoice, trayBadge, onTrayBadge,
cliStatus, onCheckCli, onProbeCli, cliChecking, onQuit,
}: Props) {
const [loginItem, setLoginItem] = useState<boolean | null>(null)
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
invoke<boolean>('launch_at_login').then(setLoginItem).catch(() => setLoginItem(false))
if (!cliStatus) onProbeCli()
// Probe once when the panel opens; cliStatus arriving later must not re-trigger it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const toggleLogin = async () => {
if (loginItem === null) return
setLoginError(null)
try {
setLoginItem(await invoke<boolean>('set_launch_at_login', { enabled: !loginItem }))
} catch (err) {
setLoginError(err instanceof Error ? err.message : String(err))
}
}
return (
<section className="settings">
<div className="settings-head">
<button type="button" className="btn btn-icon" onClick={onBack} aria-label="Back">
<ChevronRight size={11} style={{ transform: 'rotate(180deg)' }} />
</button>
<span className="settings-title">Settings</span>
</div>
<div className="settings-group">
<div className="settings-group-label">General</div>
<Row label="Launch at login" hint="Start CodeBurn in the tray when you sign in.">
<Toggle on={loginItem === true} disabled={loginItem === null} onToggle={toggleLogin} />
</Row>
{loginError && <div className="settings-error">{loginError}</div>}
<Row label="Show today's cost in the tray" hint="A second tray icon carrying the number, next to the logo.">
<Toggle on={trayBadge} onToggle={() => onTrayBadge(!trayBadge)} />
</Row>
</div>
<div className="settings-group">
<div className="settings-group-label">Appearance</div>
<Row label="Theme">
<div className="segmented">
{(['system', 'light', 'dark'] as ThemeChoice[]).map(t => (
<button
key={t}
type="button"
className={`segment ${themeChoice === t ? 'segment-active' : ''}`}
aria-pressed={themeChoice === t}
onClick={() => onThemeChoice(t)}
>
{t === 'system' ? 'System' : t === 'light' ? 'Light' : 'Dark'}
</button>
))}
</div>
</Row>
<Row label="Currency" hint={`Shared with the CLI via ${homePath('.config', 'codeburn', 'config.json')}.`}>
<DropMenu
label={<><span>{currency.code}</span><ChevronDown size={10} /></>}
items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))}
columns={3}
align="right"
onSelect={onCurrency}
/>
</Row>
</div>
<div className="settings-group">
<div className="settings-group-label">Data source</div>
<Row
label="CodeBurn CLI"
hint={cliStatus?.found ? `Version ${cliStatus.version ?? '?'} · ${cliStatus.program}` : 'Not found on this machine.'}
>
<button type="button" className="btn" onClick={onCheckCli} disabled={cliChecking}>
{cliChecking ? 'Checking…' : 'Check again'}
</button>
</Row>
</div>
<div className="settings-group">
<div className="settings-group-label">About</div>
<Row label={`CodeBurn Desktop ${version ? `v${version}` : ''}`} hint="Tracks AI coding spend from local session logs. Nothing leaves this machine except the Claude usage check.">
<a className="btn" href={GITHUB_URL} target="_blank" rel="noopener noreferrer">GitHub</a>
</Row>
<Row label="Quit CodeBurn" hint="Removes the tray icon until you launch it again.">
<button type="button" className="btn" onClick={onQuit}>Quit</button>
</Row>
</div>
</section>
)
}
function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="settings-row">
<div className="settings-row-text">
<div className="settings-row-label">{label}</div>
{hint && <div className="settings-row-hint">{hint}</div>}
</div>
<div className="settings-row-control">{children}</div>
</div>
)
}
function Toggle({ on, disabled = false, onToggle }: { on: boolean; disabled?: boolean; onToggle: () => void }) {
return (
<button
type="button"
role="switch"
aria-checked={on}
className={`toggle ${on ? 'toggle-on' : ''}`}
disabled={disabled}
onClick={onToggle}
>
<span className="toggle-knob" />
</button>
)
}

View file

@ -0,0 +1,71 @@
import { useState } from 'react'
import { BurnFlame } from './LoadingOverlay'
import { WarningIcon } from './Icons'
/// Shown instead of the data views when the CLI is missing or too old. This is what a
/// brand-new Windows user sees, so it has to explain the one thing they need to do.
export type CliStatus = {
found: boolean
program: string
version: string | null
min_version: string
compatible: boolean
error: string | null
}
const INSTALL_COMMAND = 'npm install -g codeburn'
type Props = {
status: CliStatus
checking: boolean
onCheckAgain: () => void
}
export function SetupState({ status, checking, onCheckAgain }: Props) {
const [copied, setCopied] = useState(false)
const outdated = status.found && !status.compatible
const copy = async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
setCopied(false)
}
}
return (
<section className="setup">
<BurnFlame size={44} />
<h2 className="setup-title">
{outdated ? 'Update the CodeBurn CLI' : 'Install the CodeBurn CLI'}
</h2>
<p className="setup-copy">
{outdated
? `This app needs codeburn ${status.min_version} or newer; version ${status.version} was found.`
: 'The tray app reads everything through the codeburn command line tool, which is not installed on this machine yet.'}
</p>
<div className="setup-command">
<code>{INSTALL_COMMAND}</code>
<button type="button" className="btn" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
</div>
<p className="setup-copy setup-copy-muted">
Requires Node.js 22 or newer. After installing, click Check again; no restart needed.
</p>
<div className="setup-actions">
<button type="button" className="btn btn-prominent" onClick={onCheckAgain} disabled={checking}>
{checking ? 'Checking…' : 'Check again'}
</button>
</div>
{status.error && (
<details className="setup-details">
<summary><WarningIcon size={9} filled={false} /> Details</summary>
<div className="setup-error">{status.error}</div>
<div className="setup-error-muted">Looked for: {status.program}</div>
</details>
)}
</section>
)
}

View file

@ -0,0 +1,29 @@
import { useState } from 'react'
import { readSetting, writeSetting } from '../lib/settings'
import { StarIcon, XIcon } from './Icons'
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
export function StarBanner() {
const [dismissed, setDismissed] = useState(() => readSetting('starBannerDismissed') === 'true')
if (dismissed) return null
const dismiss = () => {
writeSetting('starBannerDismissed', 'true')
setDismissed(true)
}
return (
<div className="star-banner">
<StarIcon size={10} className="star-banner-icon" />
<a className="star-banner-link" href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
<span>Enjoying CodeBurn?</span>{' '}
<span className="star-banner-cta">Star us on GitHub</span>
</a>
<span className="star-banner-spacer" />
<button type="button" className="star-banner-close" onClick={dismiss} title="Hide this banner" aria-label="Hide this banner">
<XIcon size={9} />
</button>
</div>
)
}

View file

@ -0,0 +1,63 @@
import type { MenubarPayload } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCurrency, formatCompactCurrency, plural } from '../lib/currency'
import { daysInMonth, monthDay } from '../lib/dates'
import { computeHistoryStats } from '../lib/history'
import type { Period } from './PeriodTabs'
type Props = {
payload: MenubarPayload
currency: CurrencyState
period: Period
}
const PERIOD_SUFFIX: Record<Period, string> = {
today: 'today',
week: '(7 days)',
'30days': '(30 days)',
month: '(month)',
all: '(all time)',
}
export function StatsInsight({ payload, currency, period }: Props) {
const s = computeHistoryStats(payload.history.daily)
const suffix = PERIOD_SUFFIX[period]
return (
<div className="stats-insight">
<div className="stats-grid">
<div className="stats-col">
<StatRow label="Favorite model" value={payload.current.topModels[0]?.name ?? '-'} />
<StatRow label="Active days (month)" value={`${s.activeDaysThisMonth}/${daysInMonth(new Date())}`} />
<StatRow label="Most active day" value={s.peak ? monthDay(s.peak.date) : '-'} />
<StatRow label="Peak day spend" value={s.peak ? formatCompactCurrency(s.peak.cost, currency) : '-'} />
</div>
<div className="stats-col">
<StatRow label={`Sessions ${suffix}`} value={payload.current.sessions.toLocaleString()} />
<StatRow label={`Calls ${suffix}`} value={payload.current.calls.toLocaleString()} />
<StatRow label="Current streak" value={s.currentStreak > 0 ? plural(s.currentStreak, 'day') : '-'} />
<StatRow label="Longest streak" value={s.longestStreak > 0 ? plural(s.longestStreak, 'day') : '-'} />
</div>
</div>
{s.trackedDays > 0 && (
<div className="stats-lifetime">
<span className="stats-lifetime-label">
Tracked spend (last {plural(s.trackedDays, 'day')})
</span>
<span className="stats-lifetime-value">
{formatCurrency(s.trackedTotal, currency)}
</span>
</div>
)}
</div>
)
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="stat-row">
<div className="stat-row-label">{label}</div>
<div className="stat-row-value">{value}</div>
</div>
)
}

View file

@ -0,0 +1,153 @@
import { useState } from 'react'
import type { DailyEntry, DailyModel } from '../lib/payload'
import type { CurrencyState } from '../lib/currency'
import { formatCompactCurrency, formatCurrency, formatTokens } from '../lib/currency'
import { todayKey, formatDateKey, addDays, startOfDay, prettyDate, shortDate } from '../lib/dates'
import { ArrowUpRight, ArrowDownRight } from './Icons'
/// 19 columns of 13px bars with 4px gaps = 319px, the widest chart that fits the 332px
/// content width of a 360px popover (mirrors mac trendDays / trendBarWidth / trendBarGap).
export const TREND_DAYS = 19
const MAX_TOOLTIP_MODELS = 4
const MIN_BAR_PCT = 2
type TrendBar = {
date: string
cost: number
tokens: number
isToday: boolean
topModels: DailyModel[]
}
function buildBars(days: DailyEntry[]): TrendBar[] {
const byDate = new Map(days.map(d => [d.date, d]))
const today = startOfDay(new Date())
const tk = todayKey()
const bars: TrendBar[] = []
for (let i = TREND_DAYS - 1; i >= 0; i--) {
const key = formatDateKey(addDays(today, -i))
const entry = byDate.get(key)
bars.push({
date: key,
cost: entry?.cost ?? 0,
tokens: (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0),
isToday: key === tk,
topModels: entry?.topModels ?? [],
})
}
return bars
}
function computeDelta(bars: TrendBar[], allDays: DailyEntry[]): number | null {
const thisTotal = bars.reduce((s, b) => s + b.cost, 0)
const today = startOfDay(new Date())
const priorStart = formatDateKey(addDays(today, -(2 * TREND_DAYS - 1)))
const thisStart = formatDateKey(addDays(today, -(TREND_DAYS - 1)))
const priorTotal = allDays
.filter(d => d.date >= priorStart && d.date < thisStart)
.reduce((s, d) => s + d.cost, 0)
if (priorTotal <= 0) return null
return ((thisTotal - priorTotal) / priorTotal) * 100
}
type Props = {
days: DailyEntry[]
currency: CurrencyState
}
export function TrendInsight({ days, currency }: Props) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
const bars = buildBars(days)
const totalTokens = bars.reduce((s, b) => s + b.tokens, 0)
const useTokens = totalTokens > 0
const metric = (b: TrendBar) => useTokens ? b.tokens : b.cost
const maxVal = Math.max(...bars.map(metric), 0.01)
const avgVal = bars.reduce((s, b) => s + metric(b), 0) / bars.length
const totalCost = bars.reduce((s, b) => s + b.cost, 0)
const peak = bars.filter(b => metric(b) > 0).sort((a, b) => metric(b) - metric(a))[0]
const yd = formatDateKey(addDays(startOfDay(new Date()), -1))
const yesterday = bars.find(b => b.date === yd)
const delta = computeDelta(bars, days)
const fmtVal = (v: number) => useTokens ? `${formatTokens(v)} tok` : formatCompactCurrency(v, currency)
const heroText = useTokens ? `${formatTokens(totalTokens)} tokens` : formatCurrency(totalCost, currency)
const hovered = hoveredIdx !== null ? bars[hoveredIdx] : null
return (
<div className="trend-insight">
<div className="insight-header">
<div>
<div className="insight-sublabel">Last {TREND_DAYS} days</div>
<div className="insight-hero">{heroText}</div>
</div>
{delta !== null && (
<div className="delta-badge">
{delta >= 0 ? <ArrowUpRight size={9} /> : <ArrowDownRight size={9} />}
<span>{delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d</span>
</div>
)}
</div>
<div className="trend-chart" onMouseLeave={() => setHoveredIdx(null)}>
<div className="trend-bars">
{bars.map((bar, i) => {
const val = metric(bar)
const pct = (val / maxVal) * 100
const cls = [
'trend-bar',
bar.isToday ? 'trend-bar-today' : '',
val <= 0 ? 'trend-bar-empty' : '',
hoveredIdx === i ? 'trend-bar-hovered' : '',
].join(' ')
return (
<div
key={bar.date}
className="trend-bar-col"
onMouseEnter={() => setHoveredIdx(i)}
>
<div className={cls} style={{ height: `${Math.max(MIN_BAR_PCT, pct)}%` }} />
</div>
)
})}
</div>
<div
className="trend-avg-line"
style={{ bottom: `${Math.min((avgVal / maxVal) * 100, 100)}%` }}
/>
{hovered && (
<div className="bar-tooltip" role="tooltip">
<div className="bar-tooltip-header">
<span>{prettyDate(hovered.date)}</span>
<span className="bar-tooltip-value">{fmtVal(metric(hovered))}</span>
</div>
{hovered.topModels.slice(0, MAX_TOOLTIP_MODELS).map(m => (
<div key={m.name} className="bar-tooltip-model">
<span className="bar-tooltip-dot" />
<span className="bar-tooltip-name">{m.name}</span>
<span className="bar-tooltip-tokens">{formatTokens(m.inputTokens + m.outputTokens)} tok</span>
<span className="bar-tooltip-split">({formatTokens(m.inputTokens)}/{formatTokens(m.outputTokens)})</span>
</div>
))}
</div>
)}
</div>
<div className="mini-stats">
<div className="mini-stat">
<div className="mini-stat-label">Avg/day</div>
<div className="mini-stat-value">{fmtVal(avgVal)}</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Peak</div>
<div className="mini-stat-value">
{peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '-'}
</div>
</div>
<div className="mini-stat">
<div className="mini-stat-label">Yesterday</div>
<div className="mini-stat-value">{yesterday ? fmtVal(metric(yesterday)) : '-'}</div>
</div>
</div>
</div>
)
}

43
windows/src/lib/cache.ts Normal file
View file

@ -0,0 +1,43 @@
/// Per (period, provider) payload cache. Entries are served instantly on tab switches and
/// refreshed in the background (stale-while-revalidate); `age` lets the caller decide
/// whether a background refresh is due.
interface CacheEntry<T> {
data: T
ts: number
}
export class PayloadCache<T> {
private store = new Map<string, CacheEntry<T>>()
private flights = new Set<string>()
private key(period: string, provider: string): string {
return `${period}:${provider}`
}
get(period: string, provider: string): T | null {
return this.store.get(this.key(period, provider))?.data ?? null
}
/// Milliseconds since the entry was stored, or Infinity when absent.
age(period: string, provider: string): number {
const entry = this.store.get(this.key(period, provider))
return entry ? Date.now() - entry.ts : Number.POSITIVE_INFINITY
}
set(period: string, provider: string, data: T): void {
this.store.set(this.key(period, provider), { data, ts: Date.now() })
}
isInFlight(period: string, provider: string): boolean {
return this.flights.has(this.key(period, provider))
}
markInFlight(period: string, provider: string): void {
this.flights.add(this.key(period, provider))
}
clearInFlight(period: string, provider: string): void {
this.flights.delete(this.key(period, provider))
}
}

View file

@ -0,0 +1,70 @@
/// Currency formatting that mirrors the macOS app's Double.asCurrency / asCompactCurrency.
/// The Rust backend hands us { code, symbol, rate } so the frontend stays dumb about FX --
/// it just multiplies and renders.
export type CurrencyState = {
code: string
symbol: string
rate: number
}
export const USD: CurrencyState = { code: 'USD', symbol: '$', rate: 1 }
export const CURRENCY_CODES = [
'USD', 'GBP', 'EUR', 'AUD', 'CAD', 'NZD', 'JPY', 'CHF', 'INR',
'BRL', 'SEK', 'SGD', 'HKD', 'KRW', 'MXN', 'ZAR', 'DKK',
] as const
const SUB_CENT = 0.005
/// Wider format with thousands separators. Used for the hero value.
export function formatCurrency(usdAmount: number, currency: CurrencyState): string {
const converted = usdAmount * currency.rate
const parts = converted.toFixed(2).split('.')
const whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `${currency.symbol}${whole}.${parts[1]}`
}
/// Compact form (no thousands separators) used in dense tables where the monospace font
/// already gives visual grouping.
export function formatCompactCurrency(usdAmount: number, currency: CurrencyState): string {
const converted = usdAmount * currency.rate
return `${currency.symbol}${converted.toFixed(2)}`
}
/// For savings and other tiny amounts: never print a misleading "$0.00".
export function formatSmallCurrency(usdAmount: number, currency: CurrencyState): string {
const converted = usdAmount * currency.rate
if (converted > 0 && converted < SUB_CENT) return `<${currency.symbol}0.01`
return formatCompactCurrency(usdAmount, currency)
}
/// Token compaction shared by every surface (the mac app rounds K to whole numbers).
export function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`
return `${Math.round(n)}`
}
const BADGE_THOUSAND = 1_000
const BADGE_MILLION = 1_000_000
/// The spend string drawn into the tray icon. Budget is a 16px-wide pixel grid, so at most
/// four glyph slots: "$9.5", "$87", "142", "1.2K", "12K", "0.1M". The `$` only fits when
/// there are two digits or fewer, and only USD has a glyph in the icon font.
export function trayBadgeText(usdAmount: number, currency: CurrencyState): string {
const v = Math.max(0, usdAmount * currency.rate)
const symbol = currency.code === 'USD' ? '$' : ''
// Thresholds sit at the rounding boundary of the format above them, so "9.96" becomes
// "$10" rather than "$10.0" and "999.7" becomes "1.0K" rather than "1000".
if (v < 9.95) return `${symbol}${v.toFixed(1)}`
if (v < 99.5) return `${symbol}${Math.round(v)}`
if (v < 999.5) return `${Math.round(v)}`
if (v < 9_950) return `${(v / BADGE_THOUSAND).toFixed(1)}K`
if (v < 999_500) return `${Math.round(v / BADGE_THOUSAND)}K`
return `${(v / BADGE_MILLION).toFixed(1)}M`
}
export function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
return `${n} ${n === 1 ? singular : pluralForm}`
}

89
windows/src/lib/dates.ts Normal file
View file

@ -0,0 +1,89 @@
/// All calendar math is in the machine's local time zone. The CLI buckets `history.daily`
/// by local date, so "today" here must be the same local day or the trend chart and the
/// hero disagree around midnight.
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]
export const MS_PER_DAY = 86_400_000
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n)
}
export function todayKey(): string {
return formatDateKey(new Date())
}
export function formatDateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
export function parseDateKey(ymd: string): Date {
const [y, m, d] = ymd.split('-').map(Number)
return new Date(y, m - 1, d)
}
export function addDays(d: Date, n: number): Date {
const r = new Date(d.getTime())
r.setDate(r.getDate() + n)
return r
}
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
export function prettyDate(ymd: string): string {
const dt = parseDateKey(ymd)
return `${DAY_NAMES[dt.getDay()]} ${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}`
}
export function monthDay(ymd: string): string {
const dt = parseDateKey(ymd)
return `${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}`
}
export function shortDate(ymd: string): string {
const parts = ymd.split('-')
return `${parts[1]}/${parts[2]}`
}
export function firstOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
export function daysInMonth(d: Date): number {
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
}
export function dayOfMonth(d: Date): number {
return d.getDate()
}
export function previousMonthRange(d: Date): { first: string; last: string } {
const first = new Date(d.getFullYear(), d.getMonth() - 1, 1)
const last = new Date(d.getFullYear(), d.getMonth(), 0)
return { first: formatDateKey(first), last: formatDateKey(last) }
}
/// "in 42m", "in 3h", "in 2d", or "now".
export function relativeFuture(target: Date, now = new Date()): string {
const secs = (target.getTime() - now.getTime()) / 1000
if (secs <= 0) return 'now'
if (secs < 3600) return `in ${Math.ceil(secs / 60)}m`
if (secs < 86_400) return `in ${Math.ceil(secs / 3600)}h`
return `in ${Math.ceil(secs / 86_400)}d`
}
/// "just now", "2 min ago", "1 h ago".
export function relativePast(target: Date, now = new Date()): string {
const secs = Math.max(0, (now.getTime() - target.getTime()) / 1000)
if (secs < 45) return 'just now'
if (secs < 3600) return `${Math.round(secs / 60)} min ago`
if (secs < 86_400) return `${Math.round(secs / 3600)} h ago`
return `${Math.round(secs / 86_400)} d ago`
}

View file

@ -0,0 +1,99 @@
import type { DailyEntry } from './payload'
import {
formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth, dayOfMonth,
previousMonthRange, parseDateKey, MS_PER_DAY,
} from './dates'
/// Derived numbers over `history.daily` that several insights share (Trend, Forecast,
/// Stats, Tips). One implementation so the streak in Tips and the streak in Stats agree.
const MAX_STREAK_LOOKBACK_DAYS = 400
const WEEK_DAYS = 7
export type HistoryStats = {
weekTotal: number
priorWeekTotal: number
weekDelta: number | null
yesterday: number
monthToDate: number
monthProjection: number
previousMonthTotal: number | null
activeDaysThisMonth: number
currentStreak: number
longestStreak: number
peak: DailyEntry | null
trackedTotal: number
trackedDays: number
}
export function computeHistoryStats(history: DailyEntry[], now = new Date()): HistoryStats {
const today = startOfDay(now)
const costByDate = new Map(history.map(d => [d.date, d.cost]))
const sum = (from: string, to: string) =>
history.filter(d => d.date >= from && d.date <= to).reduce((s, d) => s + d.cost, 0)
const todayKey = formatDateKey(today)
const weekStart = formatDateKey(addDays(today, -(WEEK_DAYS - 1)))
const priorWeekStart = formatDateKey(addDays(today, -(2 * WEEK_DAYS - 1)))
const priorWeekEnd = formatDateKey(addDays(today, -WEEK_DAYS))
const weekTotal = sum(weekStart, todayKey)
const priorWeekTotal = sum(priorWeekStart, priorWeekEnd)
const weekDelta = priorWeekTotal > 0 ? ((weekTotal - priorWeekTotal) / priorWeekTotal) * 100 : null
const yesterday = costByDate.get(formatDateKey(addDays(today, -1))) ?? 0
const fomKey = formatDateKey(firstOfMonth(now))
const monthToDate = sum(fomKey, todayKey)
const dom = dayOfMonth(now)
const monthProjection = dom > 0 ? (monthToDate / dom) * daysInMonth(now) : 0
const prev = previousMonthRange(now)
const prevEntries = history.filter(d => d.date >= prev.first && d.date <= prev.last)
const previousMonthTotal = prevEntries.length > 0 ? prevEntries.reduce((s, d) => s + d.cost, 0) : null
const activeDaysThisMonth = history.filter(d => d.date >= fomKey && d.cost > 0).length
let currentStreak = 0
for (let i = 0; i < MAX_STREAK_LOOKBACK_DAYS; i++) {
if ((costByDate.get(formatDateKey(addDays(today, -i))) ?? 0) > 0) currentStreak++
else break
}
let longestStreak = 0
if (history.length > 0) {
const first = parseDateKey([...history].sort((a, b) => a.date.localeCompare(b.date))[0].date)
const totalDays = Math.min(
MAX_STREAK_LOOKBACK_DAYS,
Math.round((today.getTime() - first.getTime()) / MS_PER_DAY) + 1,
)
const start = addDays(today, -(totalDays - 1))
let running = 0
for (let i = 0; i < totalDays; i++) {
if ((costByDate.get(formatDateKey(addDays(start, i))) ?? 0) > 0) {
running++
longestStreak = Math.max(longestStreak, running)
} else {
running = 0
}
}
}
const peak = history.reduce<DailyEntry | null>(
(best, d) => (!best || d.cost > best.cost) ? d : best, null,
)
return {
weekTotal,
priorWeekTotal,
weekDelta,
yesterday,
monthToDate,
monthProjection,
previousMonthTotal,
activeDaysThisMonth,
currentStreak,
longestStreak,
peak: peak && peak.cost > 0 ? peak : null,
trackedTotal: history.reduce((s, d) => s + d.cost, 0),
trackedDays: history.length,
}
}

View file

@ -0,0 +1,57 @@
/// Shape of the JSON returned by `codeburn status --format menubar-json`. Kept in sync with
/// `src/menubar-json.ts` (CLI) and `mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift`
/// (macOS app). Any field change there must land here too or the frontend silently drops it.
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheHitPercent: number
topActivities: Activity[]
topModels: Model[]
providers: Record<string, number>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{ title: string; impact: 'high' | 'medium' | 'low'; savingsUSD: number }>
}
history: { daily: DailyEntry[] }
}
export type Activity = {
name: string
cost: number
turns: number
oneShotRate: number | null
}
export type Model = {
name: string
cost: number
calls: number
}
export type DailyModel = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels?: DailyModel[]
}

73
windows/src/lib/plan.ts Normal file
View file

@ -0,0 +1,73 @@
/// Claude subscription usage as returned by the Rust `plan_usage` command, plus the
/// projection math from the macOS PlanInsight so both apps draw the same marker.
export type PlanWindow = {
key: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet' | string
label: string
percent: number
resets_at: string | null
previous_final: number | null
}
export type PlanUsage =
| { state: 'ok'; tier: string; raw_tier: string | null; windows: PlanWindow[]; fetched_at: string }
| { state: 'no_credentials' }
| { state: 'failed'; message: string }
export type PlanProjection = {
percent: number
willOverflow: boolean
hitsLimitAt: Date | null
source: 'linear' | 'historical'
}
const FIVE_HOUR_SECONDS = 5 * 3600
const SEVEN_DAY_SECONDS = 7 * 86_400
/// Below this fraction of the window the linear extrapolation is noise; fall back to
/// last cycle's final reading instead.
const FRESH_WINDOW_THRESHOLD = 0.05
const FULL_PERCENT = 100
function windowSeconds(key: string): number {
return key === 'five_hour' ? FIVE_HOUR_SECONDS : SEVEN_DAY_SECONDS
}
export function projectWindow(window: PlanWindow, now = new Date()): PlanProjection | null {
if (!window.resets_at) return null
const resetsAt = new Date(window.resets_at)
if (Number.isNaN(resetsAt.getTime())) return null
const seconds = windowSeconds(window.key)
const windowStart = resetsAt.getTime() / 1000 - seconds
const elapsed = now.getTime() / 1000 - windowStart
const elapsedFraction = elapsed / seconds
if (elapsedFraction > FRESH_WINDOW_THRESHOLD && window.percent > 0) {
const projected = window.percent / elapsedFraction
let hitsLimitAt: Date | null = null
if (projected > FULL_PERCENT && window.percent < FULL_PERCENT) {
const percentPerSecond = window.percent / elapsed
if (percentPerSecond > 0) {
hitsLimitAt = new Date(now.getTime() + ((FULL_PERCENT - window.percent) / percentPerSecond) * 1000)
}
}
return { percent: projected, willOverflow: projected > FULL_PERCENT, hitsLimitAt, source: 'linear' }
}
if (window.previous_final != null) {
return {
percent: window.previous_final,
willOverflow: window.previous_final > FULL_PERCENT,
hitsLimitAt: null,
source: 'historical',
}
}
return null
}
export function earliestReset(windows: PlanWindow[]): Date | null {
const dates = windows
.map(w => (w.resets_at ? new Date(w.resets_at) : null))
.filter((d): d is Date => d !== null && !Number.isNaN(d.getTime()))
if (dates.length === 0) return null
return dates.reduce((a, b) => (a < b ? a : b))
}

View file

@ -0,0 +1,10 @@
/// Paths shown in copy use the reader's own OS spelling.
export const IS_WINDOWS = navigator.userAgent.includes('Windows')
const HOME = IS_WINDOWS ? '%USERPROFILE%' : '~'
const SEP = IS_WINDOWS ? '\\' : '/'
export function homePath(...parts: string[]): string {
return [HOME, ...parts].join(SEP)
}

View file

@ -0,0 +1,42 @@
/// The few preferences that live in the webview (everything the CLI also needs, like the
/// currency, lives in ~/.config/codeburn/config.json via the Rust side).
const KEYS = {
theme: 'codeburn.theme',
insight: 'codeburn.insight',
starBannerDismissed: 'codeburn.starBannerDismissed',
trayBadge: 'codeburn.trayBadge',
} as const
type Key = keyof typeof KEYS
export function readSetting(key: Key): string | null {
try {
return localStorage.getItem(KEYS[key])
} catch {
return null
}
}
export function writeSetting(key: Key, value: string | null): void {
try {
if (value === null) localStorage.removeItem(KEYS[key])
else localStorage.setItem(KEYS[key], value)
} catch {
// Storage can be unavailable in a locked-down webview; preferences are best-effort.
}
}
export type Theme = 'light' | 'dark'
export function currentTheme(): Theme {
const stamped = document.documentElement.getAttribute('data-theme')
if (stamped === 'dark' || stamped === 'light') return stamped
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function applyTheme(theme: Theme | null): void {
if (theme) document.documentElement.setAttribute('data-theme', theme)
else document.documentElement.removeAttribute('data-theme')
writeSetting('theme', theme)
}

48
windows/src/lib/tips.ts Normal file
View file

@ -0,0 +1,48 @@
import type { MenubarPayload } from './payload'
import type { CurrencyState } from './currency'
import { formatCompactCurrency, formatSmallCurrency } from './currency'
import { computeHistoryStats } from './history'
export type TipItem = { text: string; trailing: string | null }
export type TipGroup = { label: string; icon: string; items: TipItem[] }
const CACHE_HIT_GOOD = 80
const CACHE_HIT_LOW = 50
const ONESHOT_GOOD = 0.75
const ONESHOT_LOW = 0.5
const SPEND_DOWN_THRESHOLD = -10
const SPEND_UP_THRESHOLD = 25
const STREAK_MILESTONE = 5
const MONTH_GROWTH_WARNING = 1.3
const TOP_FINDINGS_COUNT = 3
export function computeTipGroups(payload: MenubarPayload, currency: CurrencyState): TipGroup[] {
const stats = computeHistoryStats(payload.history.daily)
const { cacheHitPercent, oneShotRate } = payload.current
const wins: TipItem[] = []
if (cacheHitPercent >= CACHE_HIT_GOOD) wins.push({ text: `Cache hit at ${Math.round(cacheHitPercent)}% - most prompts reuse cache`, trailing: null })
if (oneShotRate != null && oneShotRate >= ONESHOT_GOOD) wins.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - edits landing first try`, trailing: null })
if (stats.weekDelta != null && stats.weekDelta < SPEND_DOWN_THRESHOLD) wins.push({ text: `Spend down ${Math.round(Math.abs(stats.weekDelta))}% vs last 7 days`, trailing: null })
if (stats.currentStreak >= STREAK_MILESTONE) wins.push({ text: `${stats.currentStreak}-day usage streak`, trailing: null })
const improvements: TipItem[] = payload.optimize.topFindings.slice(0, TOP_FINDINGS_COUNT).map(f => ({
text: f.title,
trailing: formatSmallCurrency(f.savingsUSD, currency),
}))
const risks: TipItem[] = []
if (stats.weekDelta != null && stats.weekDelta > SPEND_UP_THRESHOLD) risks.push({ text: `Spend up ${Math.round(stats.weekDelta)}% vs prior 7 days`, trailing: null })
if (cacheHitPercent > 0 && cacheHitPercent < CACHE_HIT_LOW) risks.push({ text: `Cache hit only ${Math.round(cacheHitPercent)}% - paying for cold prompts`, trailing: null })
if (oneShotRate != null && oneShotRate < ONESHOT_LOW) risks.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - lots of iteration`, trailing: null })
if (stats.previousMonthTotal != null && stats.previousMonthTotal > 0 && stats.monthProjection > stats.previousMonthTotal * MONTH_GROWTH_WARNING) {
const pct = Math.round(((stats.monthProjection - stats.previousMonthTotal) / stats.previousMonthTotal) * 100)
risks.push({ text: `On pace for ${formatCompactCurrency(stats.monthProjection, currency)} this month (+${pct}% vs last)`, trailing: null })
}
return [
{ label: "What's working", icon: 'check', items: wins },
{ label: 'What to improve', icon: 'up', items: improvements },
{ label: 'Risks', icon: 'warn', items: risks },
]
}

10
windows/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

1153
windows/src/styles.css Normal file

File diff suppressed because it is too large Load diff

75
windows/tokens.json Normal file
View file

@ -0,0 +1,75 @@
{
"version": 1,
"_comment": "Canonical design tokens for CodeBurn. The Swift mac/ app reads this at build time to populate Theme.swift; the windows/ Tauri frontend imports it as CSS custom properties. Side-by-side screenshots on macOS and Linux should read as the same product.",
"color": {
"brand": {
"accent": "#C9521D",
"accentDark": "#E8774A",
"emberDeep": "#8B3E13",
"emberGlow": "#F0A070",
"bright": "#FF7A2A"
},
"surface": {
"light": "#FAF7F3",
"dark": "#1C1816",
"elevated": "#FFFFFF",
"elevatedDark": "#2A2320"
},
"text": {
"primary": "#1C1816",
"primaryDark": "#FAF7F3",
"secondary": "#6E5D53",
"secondaryDark": "#B5A49A",
"tertiary": "#A0897D",
"tertiaryDark": "#8A7A70"
},
"categorical": {
"claude": "#C9521D",
"cursor": "#4A7D5C",
"codex": "#5C7CA3",
"pi": "#8B5A9C",
"copilot":"#B8944C"
}
},
"type": {
"family": {
"sans": "Inter, 'Segoe UI Variable', 'SF Pro Text', system-ui, sans-serif",
"mono": "'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, monospace",
"rounded": "'SF Pro Rounded', Inter, system-ui, sans-serif"
},
"scale": {
"hint": 9.5,
"caption": 10.5,
"body": 11.5,
"label": 12.5,
"heading": 16,
"hero": 32
},
"weight": {
"regular": 400,
"medium": 500,
"semibold": 600
}
},
"spacing": {
"xs": 2,
"sm": 6,
"md": 10,
"lg": 14,
"xl": 20
},
"radius": {
"sm": 3,
"md": 6,
"lg": 8,
"pill": 999
},
"layout": {
"popoverWidth": 360,
"popoverHeight": 660,
"activityBarWidth": 56,
"trendBarWidth": 13,
"trendBarGap": 4,
"trendChartHeight": 90
}
}

24
windows/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "src-tauri"]
}

29
windows/vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Tauri expects a fixed dev-server port so the Rust webview can connect reliably. The
// `@tauri-apps/plugin-*` runtime expects these HMR + strictPort settings to mirror what
// `tauri dev` spawns; tweaking them breaks the IPC bridge on first boot.
const TAURI_DEV_PORT = 1420
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: TAURI_DEV_PORT,
strictPort: true,
host: process.env.TAURI_DEV_HOST || false,
hmr: process.env.TAURI_DEV_HOST
? { protocol: 'ws', host: process.env.TAURI_DEV_HOST, port: 1421 }
: undefined,
watch: {
ignored: ['**/src-tauri/**'],
},
},
envPrefix: ['VITE_', 'TAURI_ENV_*'],
build: {
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
}))