Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ publishing empty notes.

### Added

- **The Usage view can show the plan's own meter.** <kbd>m</kbd> 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.
- **A SPEND card that prices seats, and a TOKEN COST card that prices tokens.**
The Overview's old SPEND figure — the window's tokens at API list rates — now
sits under the name it deserved, **TOKEN COST**. The **SPEND** card answers
Expand Down
31 changes: 31 additions & 0 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 Down Expand Up @@ -205,6 +206,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
62 changes: 62 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,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 @@ -286,6 +298,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 @@ -323,6 +339,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 @@ -812,6 +829,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 @@ -1035,6 +1055,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
32 changes: 32 additions & 0 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
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::{tooling, usage, Scan, Timings};
Expand All @@ -51,6 +52,7 @@ pub fn scan() -> (Scan, Timings) {
#[cfg(feature = "sqlite")]
sites: sites(),
usage: usage(),
metering: metering(),
failed: Vec::new(),
demo: true,
},
Expand Down Expand Up @@ -209,6 +211,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
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ fn handle_key(app: &mut App, key: event::KeyEvent) {
KeyCode::Char('w') => app.cycle_granularity(),
KeyCode::Char('d') => app.toggle_detail(),
KeyCode::Char('u') => app.toggle_unit(),
KeyCode::Char('m') => app.toggle_metering(),
KeyCode::Enter => app.toggle_focus(),
KeyCode::Char('[') => app.move_bucket(1),
KeyCode::Char(']') => app.move_bucket(-1),
Expand Down
Loading