diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33fd3cc..7b39ebb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,14 @@ publishing empty notes.
### 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.
- **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
diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md
index a1ae6e0..0277744 100644
--- a/docs/guide/dashboard.md
+++ b/docs/guide/dashboard.md
@@ -37,6 +37,7 @@ and the body needs the room more than the nav does.
| enter | Switch pane, on a view that has two |
| d | Token detail line under each row, on and off |
| u | Switch the Overview chart between spend and tokens |
+| m | Switch the Usage view between tokens and metering windows |
| [/] | Move the chart cursor one bucket |
| backspace | Drop the chart cursor |
| w | Regroup the charts by day, week or month |
@@ -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
+
+m 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
diff --git a/src/app.rs b/src/app.rs
index 6b1dab6..4c6ee87 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -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 {
@@ -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,
@@ -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(),
@@ -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.
@@ -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 {
+ let mut by_day: BTreeMap> = 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::() 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
diff --git a/src/demo.rs b/src/demo.rs
index 9eadadd..f52c54b 100644
--- a/src/demo.rs
+++ b/src/demo.rs
@@ -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};
@@ -51,6 +52,7 @@ pub fn scan() -> (Scan, Timings) {
#[cfg(feature = "sqlite")]
sites: sites(),
usage: usage(),
+ metering: metering(),
failed: Vec::new(),
demo: true,
},
@@ -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.
diff --git a/src/main.rs b/src/main.rs
index 473a2d7..d4016b3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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),
diff --git a/src/scan/meter.rs b/src/scan/meter.rs
new file mode 100644
index 0000000..185add6
--- /dev/null
+++ b/src/scan/meter.rs
@@ -0,0 +1,272 @@
+//! Claude's 5-hour metering windows, from the usage history Claude Desktop
+//! already keeps beside its config.
+//!
+//! Plans are metered in two units: how *deep* each 5-hour window runs, and how
+//! *many* windows get started. The Usage view's token table answers neither —
+//! tokens say nothing about where the plan's own meter stands. Claude Desktop
+//! samples that meter every few minutes into `plan-usage-history.json`
+//! (`{t, org, u: {fh, sd}}`: epoch millis, org id, and the 5-hour / 7-day
+//! window utilisation as percentages), which is enough to reconstruct each
+//! window and the peak it reached.
+//!
+//! # What is read, and what is not
+//!
+//! Timestamps and two percentages. The org id in each sample is not kept,
+//! shown, or written anywhere — the file is parsed for `t` and `u.fh` only.
+//! Absent file (no Claude Desktop, or another platform layout) scans to an
+//! empty result and the view says so, rather than rendering a zero that would
+//! read as "no usage".
+//!
+//! # Windows are reconstructed, not reported
+//!
+//! The file stores samples, not windows. A window is inferred to start when
+//! utilisation rises from zero, resumes after a gap of at least the window
+//! length, or falls sharply (a reset while the app kept sampling). Samples
+//! only exist while Claude Desktop runs, so the count is a floor — windows
+//! started while it was closed are invisible — which is the honest direction
+//! for a "how close to the cap am I" signal to err.
+
+use std::path::{Path, PathBuf};
+
+/// The 5-hour window length, for gap detection, in milliseconds.
+const WINDOW_MS: i64 = 5 * 60 * 60 * 1000;
+
+/// A drop this many percentage points (while sampling stayed continuous) is a
+/// meter reset, not noise: utilisation only decays with time, and a decay
+/// this steep inside one sampling gap means a new window began.
+const RESET_DROP: u64 = 30;
+
+/// One reconstructed 5-hour window.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MeterWindow {
+ /// `YYYY-MM-DD` (UTC) the window started.
+ pub day: String,
+ /// Highest utilisation the window reached, in percent.
+ pub peak: u64,
+}
+
+/// Every window the history file can testify to.
+#[derive(Debug, Clone, Default)]
+pub struct Metering {
+ pub windows: Vec,
+ /// First and last sample days, so the view can say what span the count
+ /// covers rather than implying it covers the usage window.
+ pub from: Option,
+ pub to: Option,
+ /// The history file was found and parsed. `false` renders as an honest
+ /// absence, never as zero windows.
+ pub found: bool,
+}
+
+impl Metering {
+ /// Peak utilisation across every window, in percent.
+ pub fn hottest(&self) -> u64 {
+ self.windows.iter().map(|w| w.peak).max().unwrap_or(0)
+ }
+
+ /// Mean of the per-window peaks, in percent.
+ pub fn average_peak(&self) -> u64 {
+ if self.windows.is_empty() {
+ return 0;
+ }
+ let sum: u64 = self.windows.iter().map(|w| w.peak).sum();
+ (sum as f64 / self.windows.len() as f64).round() as u64
+ }
+}
+
+/// Read the metering history this machine has, if any.
+pub fn scan() -> Metering {
+ let Some(home) = crate::paths::home() else {
+ return Metering::default();
+ };
+ candidates(&home)
+ .iter()
+ .find(|p| p.is_file())
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .and_then(|raw| parse(&raw))
+ .unwrap_or_default()
+}
+
+/// Where Claude Desktop keeps the file, per platform. Checked in order.
+fn candidates(home: &Path) -> Vec {
+ vec![
+ home.join("AppData/Roaming/Claude/plan-usage-history.json"),
+ home.join("Library/Application Support/Claude/plan-usage-history.json"),
+ home.join(".config/Claude/plan-usage-history.json"),
+ ]
+}
+
+/// One sample: when, and how used the 5-hour window was.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct Sample {
+ /// Epoch milliseconds.
+ t: i64,
+ /// 5-hour window utilisation, percent.
+ fh: u64,
+}
+
+/// Parse the history file into windows.
+pub fn parse(raw: &str) -> Option {
+ let value: serde_json::Value = serde_json::from_str(raw).ok()?;
+ let samples: Vec = value
+ .get("samples")?
+ .as_array()?
+ .iter()
+ .filter_map(|s| {
+ Some(Sample {
+ t: s.get("t")?.as_i64()?,
+ fh: s.get("u")?.get("fh")?.as_u64()?,
+ })
+ })
+ .collect();
+ if samples.is_empty() {
+ return None;
+ }
+
+ Some(Metering {
+ windows: windows_of(&samples),
+ from: Some(day_of(samples.first()?.t)),
+ to: Some(day_of(samples.last()?.t)),
+ found: true,
+ })
+}
+
+/// Reconstruct windows from the sample stream. See the module doc for the
+/// three start conditions; the peak is simply the highest sample inside.
+fn windows_of(samples: &[Sample]) -> Vec {
+ let mut windows: Vec = Vec::new();
+ let mut current: Option = None;
+ let mut prev: Option = None;
+
+ for &sample in samples {
+ let starts = match prev {
+ None => sample.fh > 0,
+ Some(prev) => {
+ sample.fh > 0
+ && (prev.fh == 0
+ || sample.t - prev.t >= WINDOW_MS
+ || sample.fh + RESET_DROP < prev.fh)
+ }
+ };
+ if starts {
+ windows.extend(current.take());
+ current = Some(MeterWindow {
+ day: day_of(sample.t),
+ peak: sample.fh,
+ });
+ } else if let Some(window) = &mut current {
+ window.peak = window.peak.max(sample.fh);
+ }
+ prev = Some(sample);
+ }
+ windows.extend(current);
+ windows
+}
+
+/// `YYYY-MM-DD` in UTC from epoch milliseconds.
+fn day_of(millis: i64) -> String {
+ chrono::DateTime::from_timestamp_millis(millis)
+ .map(|d| d.format("%Y-%m-%d").to_string())
+ .unwrap_or_else(|| "unknown".to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ /// Minutes to epoch millis, from an arbitrary but fixed origin.
+ fn at(minutes: i64) -> i64 {
+ 1_753_920_000_000 + minutes * 60_000
+ }
+
+ fn history(samples: &[(i64, u64)]) -> String {
+ json!({
+ "version": 2,
+ "samples": samples.iter().map(|(t, fh)| json!({
+ "t": t, "org": "not-kept", "u": {"fh": fh, "sd": 1}
+ })).collect::>()
+ })
+ .to_string()
+ }
+
+ #[test]
+ fn a_rise_from_zero_starts_a_window_and_its_peak_is_kept() {
+ let raw = history(&[
+ (at(0), 0),
+ (at(5), 12),
+ (at(10), 40),
+ (at(15), 33), // decay inside the same window
+ ]);
+ let m = parse(&raw).unwrap();
+ assert!(m.found);
+ assert_eq!(m.windows.len(), 1);
+ assert_eq!(m.windows[0].peak, 40);
+ }
+
+ #[test]
+ fn a_sharp_drop_is_a_reset_and_a_shallow_one_is_decay() {
+ let raw = history(&[
+ (at(0), 50),
+ (at(5), 55),
+ (at(10), 15), // 40-point drop: a new window
+ (at(15), 35), // 20-point rise: same window
+ ]);
+ let m = parse(&raw).unwrap();
+ assert_eq!(m.windows.len(), 2);
+ assert_eq!(m.windows[0].peak, 55);
+ assert_eq!(m.windows[1].peak, 35);
+ }
+
+ #[test]
+ fn a_gap_of_a_window_length_starts_a_new_window() {
+ let raw = history(&[
+ (at(0), 20),
+ (at(5), 25),
+ (at(5 + 5 * 60), 10), // five hours later
+ ]);
+ let m = parse(&raw).unwrap();
+ assert_eq!(m.windows.len(), 2);
+ }
+
+ #[test]
+ fn idle_samples_start_nothing() {
+ let raw = history(&[(at(0), 0), (at(5), 0), (at(10), 0)]);
+ let m = parse(&raw).unwrap();
+ assert!(m.windows.is_empty());
+ assert_eq!(m.hottest(), 0);
+ }
+
+ #[test]
+ fn summary_figures_average_the_peaks_not_the_samples() {
+ let raw = history(&[
+ (at(0), 10),
+ (at(5), 60), // window 1 peaks at 60
+ (at(10), 2), // a 58-point drop: reset, window 2
+ (at(15), 40),
+ ]);
+ let m = parse(&raw).unwrap();
+ assert_eq!(m.windows.len(), 2);
+ assert_eq!(m.average_peak(), 50, "(60 + 40) / 2");
+ assert_eq!(m.hottest(), 60);
+ }
+
+ #[test]
+ fn an_absent_or_malformed_file_is_not_found_rather_than_zero() {
+ assert!(parse("{not json").is_none());
+ assert!(parse(r#"{"version": 2, "samples": []}"#).is_none());
+ let m = Metering::default();
+ assert!(!m.found, "absence must be distinguishable from idleness");
+ }
+
+ #[test]
+ fn the_org_id_is_never_kept() {
+ let raw = history(&[(at(0), 10)]);
+ let m = parse(&raw).unwrap();
+ let rendered = format!("{m:?}");
+ assert!(
+ !rendered.contains("not-kept"),
+ "org id leaked into the result"
+ );
+ }
+}
diff --git a/src/scan/mod.rs b/src/scan/mod.rs
index f165539..276b95d 100644
--- a/src/scan/mod.rs
+++ b/src/scan/mod.rs
@@ -12,6 +12,7 @@
//! release profile deliberately does not set `panic = "abort"`.
pub mod apps;
+pub mod meter;
#[cfg(feature = "sqlite")]
pub mod sites;
pub mod tooling;
@@ -32,6 +33,8 @@ pub struct Scan {
#[cfg(feature = "sqlite")]
pub sites: sites::Sites,
pub usage: usage::Usage,
+ /// Claude's 5-hour metering windows, from Claude Desktop's own samples.
+ pub metering: meter::Metering,
/// Sections that panicked, by name. Empty is the normal case.
pub failed: Vec<&'static str>,
/// Built by [`crate::demo`] rather than read off this machine. Never true
@@ -85,6 +88,9 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) {
.unwrap_or_default();
timings.usage_ms = mark.elapsed().as_millis();
+ // One small file, read whole; not worth a timing of its own.
+ let metering = section("metering", &mut failed, meter::scan).unwrap_or_default();
+
timings.total_ms = started.elapsed().as_millis();
(
@@ -94,6 +100,7 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) {
#[cfg(feature = "sqlite")]
sites,
usage,
+ metering,
failed,
demo: false,
},
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 288041b..72943e2 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -858,6 +858,110 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option {
row_hits(area, &table_state, 1)
}
+/// The plan's own meter: 5-hour windows per day and the peak each reached.
+///
+/// This is the number the plan is actually enforced in — the token table says
+/// how much work was done, this says how close that work came to the cap. A
+/// window at 100% that keeps going is when extra usage starts billing, so the
+/// peaks here are the early warning the token counts cannot give.
+///
+/// Claude only, from Claude Desktop's own samples; a machine without them
+/// gets the absence stated, never a zero that would read as idleness. The
+/// count is a floor — samples exist only while the app runs.
+fn draw_metering(frame: &mut Frame, area: Rect, app: &App) {
+ let metering = &app.scan.metering;
+ if !metering.found {
+ frame.render_widget(
+ Paragraph::new(vec![
+ Line::from(Span::styled(
+ "No metering history on this machine.",
+ Style::default().fg(theme::TEXT),
+ )),
+ Line::from(Span::styled(
+ "Claude Desktop samples its plan meter into plan-usage-history.json;",
+ Style::default().fg(theme::DIM),
+ )),
+ Line::from(Span::styled(
+ "without it there is nothing to reconstruct. [m] returns to tokens.",
+ Style::default().fg(theme::DIM),
+ )),
+ ])
+ .block(panel("metering windows \u{b7} [m] tokens")),
+ area,
+ );
+ return;
+ }
+
+ let days = app.metering_days();
+ let hottest = metering.hottest();
+
+ let rows: Vec = days
+ .iter()
+ .map(|d| {
+ // The bar is the day's deepest window against the cap itself —
+ // not against the hottest day — so half a bar always means half
+ // way to the meter, on every machine.
+ let bar = ((d.max_peak as f64 / 100.0) * 24.0).round() as usize;
+ let hot = d.max_peak >= 90;
+ let mark = if hot {
+ Span::styled("\u{25b2} ", Style::default().fg(theme::WARN))
+ } else {
+ Span::raw("")
+ };
+ Row::new(vec![
+ Cell::from(d.day.clone()),
+ Cell::from(thousands(d.windows as u64)),
+ Cell::from(format!("{}%", d.avg_peak)),
+ Cell::from(Line::from(vec![
+ mark,
+ Span::styled(
+ format!("{}%", d.max_peak),
+ Style::default().fg(if hot { theme::WARN } else { theme::TEXT }),
+ ),
+ ])),
+ Cell::from(Line::from(Span::styled(
+ "\u{2588}".repeat(bar),
+ Style::default().fg(if hot { theme::WARN } else { theme::SEQUENTIAL }),
+ ))),
+ ])
+ })
+ .collect();
+
+ let span = match (&metering.from, &metering.to) {
+ (Some(from), Some(to)) if from != to => format!("{from} \u{2192} {to}"),
+ (Some(from), _) => from.clone(),
+ _ => String::new(),
+ };
+ let mut title = format!(
+ "claude 5-hour windows \u{b7} {} over {span} \u{b7} avg peak {}%",
+ metering.windows.len(),
+ metering.average_peak(),
+ );
+ if hottest >= 90 {
+ title.push_str(&format!(" \u{b7} \u{25b2} hottest {hottest}%"));
+ } else {
+ title.push_str(&format!(" \u{b7} hottest {hottest}%"));
+ }
+ title.push_str(" \u{b7} [m] tokens");
+
+ let table = Table::new(
+ rows,
+ [
+ Constraint::Length(12),
+ Constraint::Length(9),
+ Constraint::Length(10),
+ Constraint::Length(10),
+ Constraint::Min(10),
+ ],
+ )
+ .header(header(&[
+ "DAY", "WINDOWS", "AVG PEAK", "MAX PEAK", "VS CAP",
+ ]))
+ .block(panel(&title));
+
+ frame.render_widget(table, area);
+}
+
// ------------------------------------------------------------------- sites
#[cfg(feature = "sqlite")]
@@ -971,6 +1075,11 @@ fn draw_sites(frame: &mut Frame, area: Rect, _app: &App) -> Option {
// ------------------------------------------------------------------- usage
fn draw_usage(frame: &mut Frame, area: Rect, app: &App) -> Option {
+ if app.metering_view {
+ draw_metering(frame, area, app);
+ return None;
+ }
+
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(45), Constraint::Min(6)])
@@ -1915,6 +2024,7 @@ fn draw_help(frame: &mut Frame, area: Rect) {
Line::from(" enter switch pane, where a view has two"),
Line::from(" d token detail under each row"),
Line::from(" u Overview chart: spend or tokens"),
+ Line::from(" m Usage view: tokens or metering windows"),
Line::from(" [ / \u{5d} move the chart cursor a bucket"),
Line::from(" backspace drop the chart cursor"),
Line::from(" click a view, a table row, or a pane"),
@@ -2040,6 +2150,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -2117,6 +2228,7 @@ mod tests {
#[cfg(feature = "sqlite")]
sites: Default::default(),
usage: Default::default(),
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -2249,6 +2361,73 @@ mod tests {
assert!(!out.contains("$0.00"));
}
+ /// `m` swaps the Usage view for the plan's own meter and back, shows the
+ /// per-day peaks, and does nothing on any other view.
+ #[test]
+ fn the_usage_view_toggles_to_metering_windows() {
+ let mut app = populated();
+ app.set_tab(Tab::Overview);
+ app.toggle_metering();
+ assert!(!app.metering_view, "a no-op off the Usage view");
+
+ app.set_tab(Tab::Usage);
+ app.scan.metering = crate::scan::meter::Metering {
+ windows: vec![
+ crate::scan::meter::MeterWindow {
+ day: "2026-07-27".to_string(),
+ peak: 75,
+ },
+ crate::scan::meter::MeterWindow {
+ day: "2026-07-27".to_string(),
+ peak: 43,
+ },
+ crate::scan::meter::MeterWindow {
+ day: "2026-07-28".to_string(),
+ peak: 95,
+ },
+ ],
+ from: Some("2026-07-27".to_string()),
+ to: Some("2026-07-28".to_string()),
+ found: true,
+ };
+ app.toggle_metering();
+ assert!(app.metering_view);
+ assert_eq!(app.row_count(), 0, "the meter has nothing to select");
+
+ let out = rendered(&app, 150, 30);
+ assert!(out.contains("claude 5-hour windows"), "the panel title");
+ assert!(out.contains("MAX PEAK"), "the table header");
+ assert!(out.contains("2026-07-27"));
+ assert!(out.contains("59%"), "avg of 75 and 43 on the hot day");
+ assert!(
+ out.contains("\u{25b2} 95%"),
+ "90%+ is flagged, not just shown"
+ );
+ assert!(out.contains("hottest 95%"), "the title carries the summary");
+
+ app.toggle_metering();
+ assert!(!app.metering_view);
+ let out = rendered(&app, 150, 30);
+ assert!(out.contains("tokens by model"), "back to the token chart");
+ }
+
+ /// A machine without the history file states the absence — never a zero
+ /// that would read as an idle plan.
+ #[test]
+ fn missing_metering_history_is_an_absence_not_a_zero() {
+ let mut app = populated();
+ app.set_tab(Tab::Usage);
+ assert!(!app.scan.metering.found, "the fixture has no history");
+ app.toggle_metering();
+
+ let out = rendered(&app, 150, 30);
+ assert!(out.contains("No metering history on this machine."));
+ assert!(!out.contains("MAX PEAK"), "no empty table pretending");
+ }
+
+ /// The SPEND card names what its dollars are: list-rate arithmetic when
+ /// every model is priced, the unpriced caveat when one is not — one line,
+ /// never both.
/// The SPEND card prices seats, not tokens: nothing known shows `–` and
/// says how to fix it, a detected plan is `≈` an estimate at list price,
/// and a tool without any figure makes the total `≥` a floor.
@@ -2283,6 +2462,7 @@ mod tests {
..Default::default()
},
failed: Vec::new(),
+ metering: Default::default(),
demo: false,
};
let mut app = App::new(
@@ -2329,6 +2509,7 @@ mod tests {
..Default::default()
},
failed: Vec::new(),
+ metering: Default::default(),
demo: false,
};
let mut cost = CostConfig::default();
@@ -2427,6 +2608,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -2472,6 +2654,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -2523,6 +2706,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -2974,6 +3158,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -3251,6 +3436,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -3329,6 +3515,7 @@ mod tests {
window_days: 30,
..Default::default()
},
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};
@@ -3352,6 +3539,7 @@ mod tests {
#[cfg(feature = "sqlite")]
sites: Default::default(),
usage: Default::default(),
+ metering: Default::default(),
failed: Vec::new(),
demo: false,
};