Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3f15dc8
Merge branches 'fix/codex-turn-context-model' and 'feat/projects-tool…
as-koshiyama Aug 1, 2026
320cff8
Merge branch 'feat/overview-ui-fixes' into adriano/dev
as-koshiyama Aug 1, 2026
0a97615
Merge branch 'feat/projects-tool-column' into adriano/dev
as-koshiyama Aug 1, 2026
6900f49
Merge branch 'feat/spend-vs-token-cost' into adriano/dev
as-koshiyama Aug 1, 2026
3cc6bf4
fix(integration): widen the token-cost card test for the five-card row
as-koshiyama Aug 1, 2026
d7d7042
Merge branch 'feat/account-plans' into adriano/dev
as-koshiyama Aug 1, 2026
4ecb38a
fix(integration): the tools-column fixture gains the plans field
as-koshiyama Aug 1, 2026
d1584af
Merge branch 'feat/account-plans' into adriano/dev
as-koshiyama Aug 1, 2026
b5da0b9
Merge branch 'feat/account-plans' into adriano/dev
as-koshiyama Aug 1, 2026
54bbe87
feat(usage): [usage.repo_aliases] folds two names for one project
as-koshiyama Aug 1, 2026
0da14a6
Merge branch 'fix/cache-rate-floor' into adriano/dev
as-koshiyama Aug 1, 2026
f1b45d9
Merge branch 'feat/repo-aliases' into adriano/dev
as-koshiyama Aug 1, 2026
cf88d91
fix(integration): the all-priced token-cost fixture gains cache rates
as-koshiyama Aug 1, 2026
e09f8c4
feat(usage): [m] swaps the Usage view for the plan's own meter
as-koshiyama Aug 1, 2026
09c971e
Merge branch 'feat/metering-view' into adriano/dev
as-koshiyama Aug 1, 2026
c4284a0
fix(integration): the queue's fixtures gain the metering field
as-koshiyama Aug 1, 2026
93307b4
Merge remote-tracking branch 'origin/main' into feat/metering-view
as-koshiyama Aug 2, 2026
32eb523
fix: main's new fixture gains the metering field
as-koshiyama Aug 2, 2026
e6bb03e
Merge branch 'feat/metering-view' into feat/overview-actual-spend
as-koshiyama Aug 2, 2026
77938b2
fix: the spend fixtures gain the metering field
as-koshiyama Aug 2, 2026
f0ed7e6
feat(ui): the Overview cards say what kind of money they are, in order
as-koshiyama Aug 2, 2026
f11833a
Merge branch 'feat/overview-actual-spend' into adriano/dev
as-koshiyama Aug 2, 2026
7a4375e
Merge remote-tracking branch 'origin/main' into adriano/dev
kleyt0n Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,44 @@ publishing empty notes.
it, instead of filing entire sessions under `unknown`/`▲ unpriced`. The
session-header read is also no longer cut off at a fixed 64 KiB, which
truncated exactly the line that names the model.

### Changed

- **The Overview cards say what kind of money they are, in order.** SPEND,
TOKENS, TOKEN COST, AVG USAGE RATE, TOOLS & SITES — the actual bill first,
the work done, the API-rate hypothetical it would have cost, how hard the
plan is being driven, the machine's inventory last. SPEND now carries only
real money: the saving-vs-API-rates line moved off it (the comparison lives
on TOKEN COST and in the Cost view), and in its place the card says whether
extra usage is in play — `no extra usage` while the metering windows have
never pegged, `▲ extra usage likely` once one has. AVG USAGE RATE is new:
the mean peak of the 5-hour metering windows, the number that predicts a cap
hit before it bills. TOOLS and AI SITES folded into one card.
- **The SPEND card says what its dollars are.** Its last qualifier now reads
`at API list rates` instead of `all models priced` — the figure is list-rate
arithmetic, not a bill, and the old line said nothing the absence of `≥`
did not already say. With unpriced models the `▲ N model(s) unpriced`
caveat still takes the line.

### Added

- **The Usage view can show the plan's own meter.** `m` swaps the token table
for Claude's 5-hour metering windows — per day, how many were started and
the peak utilisation each reached, with the deepest window drawn as a bar
against the cap itself. Reconstructed from the samples Claude Desktop
already keeps in `plan-usage-history.json`; timestamps and percentages are
all that is read, the org id in the file is never kept, and a machine
without the file gets the absence stated rather than a zero. The window
count is a floor — samples exist only while Claude Desktop runs.
- **`[usage.repo_aliases]` folds two names for one project into one row.** A
checkout with an `origin` remote reports `owner/name`; a copy of the same
code with no remote reports its folder basename; and surface never guesses
the two are the same project, because folding spend together on a string
resemblance is misattribution. The operator declares it instead —
`"HAI Neo" = "holistic-ai/hai-neo"` — and the grouping applies when the
ledger is read, never to what is stored, so history regroups retroactively
and a wrong alias is one edit away from undone. Project totals, daily rows
and the session breakdown all follow the alias.
- **Tools name the plan they are signed into, and what the seat costs.** The
Tools view gains PLAN and `$/MO` columns, and the SPEND card and Cost view
price the same figure: Claude Code's `~/.claude.json` names the seat —
Expand Down
20 changes: 20 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ a typo, and refusing to run over a typo is worse than running over a sane value.
An unknown *key*, on the other hand, is an error at startup: a misspelled setting
that silently does nothing is the worse failure.

## `[usage.repo_aliases]`

The same project legitimately earns two rows in the Projects view: a checkout
with an `origin` remote reports `owner/name`, while a copy of the same code
with no remote — a scratch workspace, an agent's own working folder — reports
its directory basename. surface never guesses that two names are one project,
because folding someone's spend together on a string resemblance is
misattribution. Declare it instead:

```toml
[usage.repo_aliases]
"HAI Neo" = "holistic-ai/hai-neo"
```

Keys are rows exactly as the Projects view shows them; values are the row to
fold them into. The grouping is applied when the ledger is *read*, never to
what is stored — like prices — so an alias added today regroups the whole
window retroactively, and a wrong one is one edit away from undone. Aliases do
not chase: an alias pointing at another alias folds one hop only.

## `[cost]`

surface prices tokens at API list rates. If you pay a flat subscription instead,
Expand Down
71 changes: 55 additions & 16 deletions docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ and the body needs the room more than the nav does.
| <kbd>enter</kbd> | Switch pane, on a view that has two |
| <kbd>d</kbd> | Token detail line under each row, on and off |
| <kbd>u</kbd> | Switch the Overview chart between spend and tokens |
| <kbd>m</kbd> | Switch the Usage view between tokens and metering windows |
| <kbd>[</kbd>/<kbd>]</kbd> | Move the chart cursor one bucket |
| <kbd>backspace</kbd> | Drop the chart cursor |
| <kbd>w</kbd> | Regroup the charts by day, week or month |
Expand All @@ -58,22 +59,26 @@ one of those is a caveat rather than a count.

| Card | Figure | Qualifiers |
|---|---|---|
| **SPEND** | What the seats actually cost, `$X/mo` | Where the figures came from (`N configured seat(s)`, `N plan(s) detected`), the saving against API rates, and `▲ N tool(s) unpriced` if any |
| **TOKEN COST** | The window's tokens at API list rates | `over N days · ≈ $X/day`, the period delta, and `▲ N model(s) unpriced` — or `at API list rates` when none are, because the figure is list-rate arithmetic, not a bill |
| **SPEND** | What is actually paid: the seats, `$X/mo` | Where the figures came from (`N configured seat(s)`, `N plan(s) detected`), whether extra usage is in play (`no extra usage`, or `▲ extra usage likely` once a metering window has pegged), and `▲ N tool(s) unpriced` if any |
| **TOKENS** | Everything billable, cache included | Message count, distinct model count |
| **TOOLS** | AI tools detected | `▲ N autonomous`, vendor count |
| **AI SITES** | Distinct AI domains visited | Visit total, and `▲ N browser(s) unreadable` if any |

SPEND and TOKEN COST are different questions about the same tokens. TOKEN COST
is arithmetic — the window's usage priced per token at list rates. SPEND is
what is actually paid for the seats behind that usage: a figure from
`[cost.subscriptions]` is shown plainly; one from a plan the tool itself names
— its account file first (`~/.claude.json`, `~/.codex/auth.json`), its
transcripts as the fallback — is priced at that plan's list rate and marked
`≈` an estimate; a tool with usage but no figure makes the total `≥` a floor.
Knowing nothing, the card shows `–` and says how to configure it. Nothing is
fetched, nothing is guessed, and the plan's *name* is the only thing read from
those files — never the credentials that sit beside it.
| **TOKEN COST** | The window's tokens at API list rates | `over N days · ≈ $X/day`, the period delta, and `▲ N model(s) unpriced` — or `at API list rates` when none are, because the figure is list-rate arithmetic, not a bill |
| **AVG USAGE RATE** | Mean peak of the 5-hour metering windows | Window count, hottest window (`▲` amber from 90%), and the pointer to <kbd>m</kbd> on Usage for the per-day breakdown |
| **TOOLS & SITES** | Installed tools `·` AI domains visited | `▲ N autonomous`, visit total, and `▲ N browser(s) unreadable` if any |

The order is deliberate: the actual bill first, then the work done, then what
that work *would* have cost, then how hard the plan is being driven, then the
machine's inventory. SPEND carries only real money — seats from
`[cost.subscriptions]` shown plainly, a plan the tool itself names (its
account file first, `~/.claude.json` and `~/.codex/auth.json`, its transcripts
as the fallback) priced at list rate and marked `≈` an estimate, a tool with
usage but no figure making the total `≥` a floor — and the API-rate
hypothetical never appears on it; that comparison lives on TOKEN COST and in
the Cost view. Knowing nothing, SPEND shows `–` and says how to configure it.
Nothing is fetched, nothing is guessed, and the plan's *name* is the only
thing read from those files — never the credentials that sit beside it. AVG
USAGE RATE comes from the same metering samples as the Usage view's
<kbd>m</kbd> toggle, and shows `–` on a machine without them rather than a
zero that would read as idleness.

### The rate and the delta

Expand Down Expand Up @@ -208,6 +213,36 @@ Every kind of token is on the detail line under each row, which is what
output rate — and both are inside **TOTAL**, so before they had a line of their
own the visible columns did not add up to the total beside them.

### Metering windows

<kbd>m</kbd> swaps this view for the plan's own meter. Claude plans are
enforced in 5-hour windows — how many get started, and how deep each runs —
and tokens say nothing about either. Claude Desktop samples that meter every
few minutes into `plan-usage-history.json`; surface reconstructs the windows
from those samples and shows, per day, how many were started and the peak each
reached:

| Column | Meaning |
|---|---|
| **DAY** | Window start date, UTC |
| **WINDOWS** | 5-hour windows started that day |
| **AVG PEAK** | Mean of the day's window peaks |
| **MAX PEAK** | The day's deepest window, `▲` amber from 90% |
| **VS CAP** | The deepest window as a bar against the cap itself — half a bar is half way to the meter, on every machine |

The title carries the whole story in one line: window count, the span the
samples cover, average peak, hottest ever. A window that reaches 100% and
keeps going is where extra usage starts billing, so the peaks here are the
early warning the token counts cannot give.

Three honest limits: the samples exist only while Claude Desktop runs, so the
window count is a floor; the reconstruction is heuristic (a sharp utilisation
drop is read as a reset); and it is Claude-only — Codex's equivalent lives in
its transcripts' `rate_limits` and is not read yet. A machine without the
history file gets that stated, never a zero that would read as idleness.
Timestamps and percentages are all that is read; the org id in the file is
never kept.

**The chart above this table is stacked by model, not by tool, and a model is the
same colour in both.** That is the point of keying them the same way: a segment in
the chart and a row in the table are visibly one thing. The swatch beside each
Expand Down Expand Up @@ -324,7 +359,11 @@ walks projects and the chart follows.
```

Attribution is by the working directory a session ran in, resolved to its git
`origin` slug — never a path, and never a branch. Work outside a repository lands
`origin` slug — never a path, and never a branch. A directory with no remote is
named by its basename, which is how one project can appear as two rows (`HAI
Neo` beside `holistic-ai/hai-neo`); declare those one project with
[`[usage.repo_aliases]`](configuration.md#usagerepo_aliases) and the rows fold
together, history included. Work outside a repository lands
in `(unattributed)`, which is a row like any other rather than a discard — see
[Repository
attribution](../getting-started/concepts.md#repository-attribution).
Expand Down
69 changes: 68 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,18 @@ impl SessionRow {
}
}

/// One day of the plan's own meter: how many 5-hour windows started, and how
/// deep they ran.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MeterDay {
pub day: String,
pub windows: usize,
/// Mean of the day's window peaks, percent.
pub avg_peak: u64,
/// Deepest window of the day, percent.
pub max_peak: u64,
}

/// A subscription against what the same usage would have cost on API rates.
#[derive(Debug, Clone)]
pub struct SubscriptionRow {
Expand Down Expand Up @@ -293,6 +305,10 @@ pub struct App {
/// tables that have one. On by default: the figures it carries are billed,
/// and they were invisible before it existed.
pub detail: bool,
/// Whether the Usage view shows the plan's own meter — the 5-hour windows
/// and the peak each reached — instead of the token table. Off by
/// default: tokens are what the view is named for.
pub metering_view: bool,
pub should_quit: bool,
pub status_line: Option<String>,

Expand Down Expand Up @@ -330,6 +346,7 @@ impl App {
bucket_back: None,
unit: Unit::Spend,
detail: true,
metering_view: false,
should_quit: false,
status_line: None,
tools: Vec::new(),
Expand Down Expand Up @@ -524,7 +541,12 @@ impl App {
let meta = self.ledger().session_meta.get(&key);
SessionRow {
tool: meta.map(|m| m.tool.clone()).unwrap_or_default(),
repo: meta.map(|m| m.repo.clone()).unwrap_or_default(),
// Canonical, like the project rows, or an aliased
// project's breakdown would come up empty: the pane
// filters sessions by the name the row above shows.
repo: meta
.map(|m| self.ledger().canonical(&m.repo).to_string())
.unwrap_or_default(),
title: meta.and_then(|m| m.title.clone()),
models: models.into_keys().collect(),
key,
Expand Down Expand Up @@ -829,6 +851,9 @@ impl App {
Tab::Overview => 0,
Tab::Tools => self.tools.len(),
Tab::Sites => self.site_count(),
// The meter has nothing to select: its rows are days, and no
// second pane hangs off them.
Tab::Usage if self.metering_view => 0,
Tab::Usage => self.models.len(),
// Nothing to select over when there are no prices; the footer should
// not advertise a movement that does nothing.
Expand Down Expand Up @@ -1052,6 +1077,48 @@ impl App {
);
}

/// Swap the Usage view between tokens and the plan's own meter. A no-op
/// on every other view, so the key cannot invisibly re-arm a mode the
/// reader is not looking at.
pub fn toggle_metering(&mut self) {
if self.tab != Tab::Usage {
return;
}
self.metering_view = !self.metering_view;
self.status_line = Some(
if self.metering_view {
"metering windows"
} else {
"tokens"
}
.to_string(),
);
}

/// The plan's own meter, grouped by day, oldest first.
///
/// Computed on read rather than cached in `rebuild`: a fortnight of
/// samples yields a few dozen windows, which is nothing next to the
/// ledger walks the cache exists for.
pub fn metering_days(&self) -> Vec<MeterDay> {
let mut by_day: BTreeMap<String, Vec<u64>> = BTreeMap::new();
for window in &self.scan.metering.windows {
by_day
.entry(window.day.clone())
.or_default()
.push(window.peak);
}
by_day
.into_iter()
.map(|(day, peaks)| MeterDay {
windows: peaks.len(),
avg_peak: (peaks.iter().sum::<u64>() as f64 / peaks.len() as f64).round() as u64,
max_peak: peaks.iter().copied().max().unwrap_or(0),
day,
})
.collect()
}

pub fn cycle_granularity(&mut self) {
self.granularity = self.granularity.next();
// Regrouping rebuilds the buckets, so "three ago" now points at a
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,22 @@ pub struct UsageConfig {
pub scan: bool,
/// How many days of daily totals to retain and report.
pub window_days: u64,
/// Project rows to fold into another, `shown name -> fold into`.
///
/// The same project legitimately earns two names: a checkout with an
/// `origin` remote reports `owner/name`, a copy of the same code with no
/// remote reports its folder basename. surface never guesses that two
/// names are one project — that would misattribute someone's spend on a
/// string resemblance — so the operator declares it here instead.
pub repo_aliases: BTreeMap<String, String>,
}

impl Default for UsageConfig {
fn default() -> Self {
Self {
scan: true,
window_days: DEFAULT_USAGE_WINDOW_DAYS,
repo_aliases: BTreeMap::new(),
}
}
}
Expand Down Expand Up @@ -212,6 +221,7 @@ mod tests {
usage: UsageConfig {
scan: true,
window_days: 0,
..Default::default()
},
..Config::default()
};
Expand Down
32 changes: 32 additions & 0 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use std::collections::BTreeMap;
use chrono::{Datelike, Duration, Utc, Weekday};

use crate::ledger::{Ledger, Tokens};
use crate::scan::meter;
#[cfg(feature = "sqlite")]
use crate::scan::sites;
use crate::scan::{plans, tooling, usage, Scan, Timings};
Expand Down Expand Up @@ -71,6 +72,7 @@ pub fn scan() -> (Scan, Timings) {
},
),
]),
metering: metering(),
failed: Vec::new(),
demo: true,
},
Expand Down Expand Up @@ -229,6 +231,36 @@ fn sites() -> sites::Sites {
}
}

// ----------------------------------------------------------------- metering

/// Fourteen days of 5-hour windows: mostly shallow, two hot days, one brush
/// with the cap — the states a viewer needs to see, none of them invented
/// past what a real fortnight looks like.
fn metering() -> meter::Metering {
let mut rng = Rng(SEED ^ 0x11E7E4);
let today = Utc::now().date_naive();
let mut windows = Vec::new();
for days_ago in (0..14).rev() {
let day = (today - Duration::days(days_ago)).format("%Y-%m-%d");
for _ in 0..rng.range(1, 3) {
let peak = match rng.range(1, 6) {
1 => rng.range(45, 92), // a hot window
_ => rng.range(2, 35), // an ordinary one
};
windows.push(meter::MeterWindow {
day: day.to_string(),
peak,
});
}
}
meter::Metering {
from: windows.first().map(|w| w.day.clone()),
to: windows.last().map(|w| w.day.clone()),
windows,
found: true,
}
}

// ------------------------------------------------------------------- usage

/// One tool's models, and the weight each carries in that tool's traffic.
Expand Down
Loading