diff --git a/README.md b/README.md index 7435250..b4a38c6 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Common flags: - `--live-limits `: `auto` tries App Server if found, `on` requires it, `off` disables live limits (default: `auto`) - `--cwd `: directory to launch Codex App Server in (default: current directory; does not change usage scope) - `--project ` / `--workspace `: filter usage stats to a specific project/workspace (also becomes default `--cwd` if `--cwd` not set) -- `--usage-days `: days to scan for usage (clamped 1..=90; default from config) +- `--usage-days `: summary/model-share window (clamped 1..=90; default from config); charts index the complete local history - `--refresh-usage-secs `: usage refresh interval in seconds (default from config) - `--refresh-limits-secs `: limits refresh interval in seconds (default from config) - `--max-session-file-mib `: per-file planning weight (MiB) for scan budget (default from config) @@ -69,7 +69,7 @@ Common flags: - `--max-session-files `: max number of session files to scan per refresh (default from config) - `--max-jsonl-line-kib `: max parsed JSONL line size in KiB (default from config) - `--scan-time-budget-ms `: max parse time budget per refresh in ms (`0` disables budget) -- `--full-scan`: scan all files under `CODEX_HOME/sessions`, including old months (ignores mtime cutoff and file/byte planning caps) +- `--full-scan`: process the complete pending session backlog in one refresh (ignores file/byte planning caps) - `--no-full-scan`: disable full scan for this run (overrides config) - `--scan-cache-max-entries `: max entries kept in cache database (`comon.db`) (default from config) - `--rebuild-cache-on-start`: delete local scan cache DB files (`comon.db`, `comon.db-wal`, `comon.db-shm`) before first usage scan @@ -130,9 +130,11 @@ comon --scan-time-budget-ms 1500 --max-jsonl-line-kib 512 ## Key bindings - `Tab` Toggle data (Tokens/Time/Runs) -- `w` Toggle timeframe (Week/Month) +- `g` / `w` Toggle grouping (Day/Week/Month) - `f` Toggle layout (Horz/Vert) +- `z` / `F6` Toggle Usage zone (Local/UTC); APISTAT always uses server UTC - `n` Cycle display formatting (Classic/System Compact/System Full) +- Mouse wheel or arrow keys Scroll chart history (`PgUp`/`PgDn`, `Home`/`End` also work) - Mouse: click the top tabs, Usage/Activity controls (including the Usage style selector), or the bottom-right Quit action - Mouse: hover a filled vertical chart bar to see its exact date and full locale-aware value - `s` / `F2` Switch between Usage and Session history @@ -341,6 +343,8 @@ CI also runs this check on each push and pull request via `.github/workflows/asc ## Notes - Usage stats are derived from Codex session JSONL logs. If you have no session data yet, values will be empty. +- Usage charts index the complete local session history and cache completed work incrementally. Until the initial backlog is complete, CoMon shows an indexing status instead of partial totals. +- APISTAT displays the server-owned UTC buckets returned by Codex App Server. USAGE reconstructs local estimates from session logs, so small differences can remain even when the date range and UTC grouping match. - Limits/credits require Codex App Server to start successfully (auth, environment, and a usable working directory). CoMon auto-detects `codex` on `PATH` and common Windows Codex App bundle locations; use `--codex-bin` or `--app-server-bin` when needed. - comon stores local app state in `~/.comon/state.json` by default (or `$COMON_HOME`, or `--comon-home`). - Display formatting starts in Classic mode; press `n` or use `STYLE CLASS/SCOMP/SFULL` in the Usage controls to choose Classic, System Compact, or System Full. Both System modes use the operating system locale for dates, times, decimals, and calendar labels; Compact uses the detected thousands separator and abbreviates dashboard token values, while Full groups expanded integers with regular spaces. The choice is saved in `state.json` without changing stored data. diff --git a/src/app/mod.rs b/src/app/mod.rs index 9c7a56e..c748c02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ -use crate::codex_rpc::{AccountRateLimits, CodexRpc}; +use crate::codex_rpc::{AccountRateLimits, AccountUsage, CodexRpc}; use crate::locale::{DisplayFormatter, DisplayStyle, SystemLocale}; use crate::read; -use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric}; +use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric, UsageZone}; use anyhow::{anyhow, Context, Result}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::backend::CrosstermBackend; @@ -46,12 +46,42 @@ pub(crate) enum ChartOrientation { Horizontal, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApiStatGrouping { + Day, + Week, + Month, +} + +impl ApiStatGrouping { + fn toggled(self) -> Self { + match self { + Self::Day => Self::Week, + Self::Week => Self::Month, + Self::Month => Self::Day, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ApiStatGraph { + Bars, + Heat, +} + #[derive(Debug, Clone, Copy)] enum UsageCommand { RefreshAll, ToggleMetric, ToggleRange, + ToggleZone, ToggleOrientation, + ScrollOlder, + ScrollNewer, + PageOlder, + PageNewer, + ScrollOldest, + ScrollNewest, ToggleHelp, ConfirmContinue, } @@ -60,13 +90,15 @@ enum UsageCommand { enum AppEvent { UsageUpdated(Result), LimitsUpdated(Result), - LimitsUnavailable { message: String, is_error: bool }, + AccountUsageUpdated(Result), + AccountApiUnavailable { message: String, is_error: bool }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ActiveScreen { Usage, Activity, + ApiStat, LimitResets, Read, } @@ -77,7 +109,20 @@ pub(crate) enum UiClickAction { SetDisplayStyle(DisplayStyle), SetMetric(UsageMetric), SetRange(ChartRange), + SetUsageZone(UsageZone), SetOrientation(ChartOrientation), + SetApiStatGrouping(ApiStatGrouping), + SetApiStatGraph(ApiStatGraph), + SetApiStatOrientation(ChartOrientation), + ScrollUsageOlder, + ScrollUsageNewer, + SetUsageScrollOffset(usize), + ScrollApiStatOlder, + ScrollApiStatNewer, + SetApiStatScrollOffset(usize), + ScrollActivityOlder, + ScrollActivityNewer, + SetActivityScrollOffset(usize), DecreaseProjects, IncreaseProjects, PromptQuit, @@ -116,6 +161,12 @@ enum ActivityCommand { ToggleMetric, IncreaseProjects, DecreaseProjects, + ScrollOlder, + ScrollNewer, + PageOlder, + PageNewer, + ScrollOldest, + ScrollNewest, ToggleHelp, } @@ -125,10 +176,26 @@ enum LimitResetCommand { ToggleHelp, } +#[derive(Debug, Clone, Copy)] +enum ApiStatCommand { + Refresh, + ToggleGrouping, + ToggleGraph, + ToggleOrientation, + ScrollOlder, + ScrollNewer, + PageOlder, + PageNewer, + ScrollOldest, + ScrollNewest, + ToggleHelp, +} + fn next_active_screen(screen: ActiveScreen) -> ActiveScreen { match screen { ActiveScreen::Usage => ActiveScreen::Activity, - ActiveScreen::Activity => ActiveScreen::LimitResets, + ActiveScreen::Activity => ActiveScreen::ApiStat, + ActiveScreen::ApiStat => ActiveScreen::LimitResets, ActiveScreen::LimitResets => ActiveScreen::Read, ActiveScreen::Read => ActiveScreen::Usage, } @@ -139,8 +206,24 @@ pub(crate) struct AppState { pub(crate) active_screen: ActiveScreen, pub(crate) metric: UsageMetric, pub(crate) range: ChartRange, + pub(crate) usage_zone: UsageZone, pub(crate) orientation: ChartOrientation, + pub(crate) api_stat_grouping: ApiStatGrouping, + pub(crate) api_stat_graph: ApiStatGraph, + pub(crate) api_stat_orientation: ChartOrientation, + pub(crate) usage_period_offset: usize, + pub(crate) usage_visible_periods: usize, + pub(crate) usage_total_periods: usize, + pub(crate) usage_scroll_area: Option, + pub(crate) api_stat_period_offset: usize, + pub(crate) api_stat_visible_periods: usize, + pub(crate) api_stat_total_periods: usize, + pub(crate) api_stat_scroll_area: Option, pub(crate) activity_project_limit: usize, + pub(crate) activity_week_offset: usize, + pub(crate) activity_visible_weeks: usize, + pub(crate) activity_total_weeks: usize, + pub(crate) activity_scroll_area: Option, pub(crate) show_help: bool, pub(crate) workspace_path: Option, pub(crate) no_sessions_confirm_open: bool, @@ -163,6 +246,12 @@ pub(crate) struct AppState { pub(crate) limits_error: Option, pub(crate) limits_notice: Option, pub(crate) limits_enabled: bool, + + pub(crate) account_usage: Option, + pub(crate) account_usage_updated_at: Option, + pub(crate) account_usage_error: Option, + pub(crate) account_usage_notice: Option, + pub(crate) account_usage_enabled: bool, pub(crate) read_sessions_dir: PathBuf, pub(crate) read_browser: crate::read::tui::BrowserState, } @@ -184,7 +273,11 @@ enum LimitsWake { struct PersistedUiState { metric: UsageMetric, range: ChartRange, + usage_zone: UsageZone, orientation: ChartOrientation, + api_stat_grouping: ApiStatGrouping, + api_stat_graph: ApiStatGraph, + api_stat_orientation: ChartOrientation, activity_project_limit: usize, workspace_path: Option, no_sessions_confirm_dismissed: bool, @@ -196,8 +289,12 @@ impl PersistedUiState { fn default_for_workspace(workspace_path: Option) -> Self { Self { metric: UsageMetric::Tokens, - range: ChartRange::Week, + range: ChartRange::Day, + usage_zone: UsageZone::Local, orientation: ChartOrientation::Horizontal, + api_stat_grouping: ApiStatGrouping::Day, + api_stat_graph: ApiStatGraph::Bars, + api_stat_orientation: ChartOrientation::Vertical, activity_project_limit: DEFAULT_ACTIVITY_PROJECT_LIMIT, workspace_path, no_sessions_confirm_dismissed: false, @@ -210,7 +307,11 @@ impl PersistedUiState { Self { metric: state.metric, range: state.range, + usage_zone: state.usage_zone, orientation: state.orientation, + api_stat_grouping: state.api_stat_grouping, + api_stat_graph: state.api_stat_graph, + api_stat_orientation: state.api_stat_orientation, activity_project_limit: state.activity_project_limit, workspace_path: state.workspace_path.clone(), no_sessions_confirm_dismissed: state.no_sessions_confirm_dismissed, @@ -243,7 +344,11 @@ impl Default for StateStore { struct StoredGlobalState { metric: Option, range: Option, + usage_zone: Option, orientation: Option, + api_stat_grouping: Option, + api_stat_graph: Option, + api_stat_orientation: Option, activity_project_limit: Option, display_style: Option, #[serde(default)] @@ -301,37 +406,25 @@ async fn run_inner( let mut usage_refresh_rx = usage_refresh_rx; let mut shutdown_rx = shutdown_rx.clone(); tokio::spawn(async move { - let mut interval = tokio::time::interval(refresh); - // Immediate first run - let first = tokio::task::spawn_blocking({ - let codex_home = codex_home.clone(); - let workspace_path = workspace_path.clone(); - let scan_cache_db_path = scan_cache_db_path.clone(); - move || { - crate::usage::compute_snapshot( - usage_days, - &codex_home, - workspace_path.as_deref(), - usage_scan_limits, - Some(scan_cache_db_path.as_path()), - ) - } - }) - .await - .unwrap_or_else(|err| Err(anyhow!("usage snapshot task failed: {err}"))); - if evt_tx.send(AppEvent::UsageUpdated(first)).await.is_err() { - return; - } - // Consume the immediate first tick so the next one waits `refresh`. - interval.tick().await; + let mut first_run = true; + let mut rapid_catch_up = false; + let mut previous_scan_progress: Option<(usize, u64)> = None; loop { - tokio::select! { - _ = shutdown_rx.changed() => break, - _ = interval.tick() => {} - recv = usage_refresh_rx.recv() => { - if recv.is_none() { break; } + if !first_run { + let wait = if rapid_catch_up { + Duration::from_millis(75) + } else { + refresh + }; + tokio::select! { + _ = shutdown_rx.changed() => break, + _ = tokio::time::sleep(wait) => {} + recv = usage_refresh_rx.recv() => { + if recv.is_none() { break; } + } } } + first_run = false; let snapshot = tokio::task::spawn_blocking({ let codex_home = codex_home.clone(); let workspace_path = workspace_path.clone(); @@ -348,6 +441,24 @@ async fn run_inner( }) .await .unwrap_or_else(|err| Err(anyhow!("usage snapshot task failed: {err}"))); + + // Keep filling a fresh/incomplete cache without waiting for the normal + // five-minute refresh. Stop the rapid loop as soon as a pass makes no + // file-level progress; an unresolved fork must not cause a CPU spin. + rapid_catch_up = snapshot.as_ref().is_ok_and(|current| { + should_continue_scan_catch_up( + current.scan_pending_files, + current.scan_indexed_files, + current.scan_processed_bytes, + previous_scan_progress, + ) + }); + if let Ok(current) = snapshot.as_ref() { + previous_scan_progress = + Some((current.scan_indexed_files, current.scan_processed_bytes)); + } else { + previous_scan_progress = None; + } if evt_tx.send(AppEvent::UsageUpdated(snapshot)).await.is_err() { break; } @@ -368,8 +479,8 @@ async fn run_inner( tokio::spawn(async move { if live_limits_mode == LiveLimitsMode::Off { let _ = evt_tx - .send(AppEvent::LimitsUnavailable { - message: "Live limits disabled (--live-limits off).".to_string(), + .send(AppEvent::AccountApiUnavailable { + message: "Codex account API disabled (--live-limits off).".to_string(), is_error: false, }) .await; @@ -380,7 +491,7 @@ async fn run_inner( crate::codex_rpc::resolve_app_server_command(codex_bin, app_server_bin) else { let _ = evt_tx - .send(AppEvent::LimitsUnavailable { + .send(AppEvent::AccountApiUnavailable { message: missing_app_server_message().to_string(), is_error: live_limits_mode == LiveLimitsMode::On, }) @@ -393,7 +504,7 @@ async fn run_inner( Ok(rpc) => rpc, Err(err) => { let _ = evt_tx - .send(AppEvent::LimitsUnavailable { + .send(AppEvent::AccountApiUnavailable { message: err.to_string(), is_error: live_limits_mode == LiveLimitsMode::On || app_server_is_explicit, @@ -410,6 +521,15 @@ async fn run_inner( rpc.kill().await; return; } + let first = rpc.read_account_usage().await; + if evt_tx + .send(AppEvent::AccountUsageUpdated(first)) + .await + .is_err() + { + rpc.kill().await; + return; + } interval.tick().await; loop { let wake = tokio::select! { @@ -447,6 +567,15 @@ async fn run_inner( rpc.kill().await; break; } + let res = rpc.read_account_usage().await; + if evt_tx + .send(AppEvent::AccountUsageUpdated(res)) + .await + .is_err() + { + rpc.kill().await; + break; + } } }); } @@ -490,8 +619,24 @@ async fn run_inner( }, metric: restored_ui_state.metric, range: restored_ui_state.range, + usage_zone: restored_ui_state.usage_zone, orientation: restored_ui_state.orientation, + api_stat_grouping: restored_ui_state.api_stat_grouping, + api_stat_graph: restored_ui_state.api_stat_graph, + api_stat_orientation: restored_ui_state.api_stat_orientation, + usage_period_offset: 0, + usage_visible_periods: 0, + usage_total_periods: 0, + usage_scroll_area: None, + api_stat_period_offset: 0, + api_stat_visible_periods: 0, + api_stat_total_periods: 0, + api_stat_scroll_area: None, activity_project_limit: restored_ui_state.activity_project_limit, + activity_week_offset: 0, + activity_visible_weeks: 0, + activity_total_weeks: crate::usage::ACTIVITY_TIMELINE_WEEKS, + activity_scroll_area: None, show_help: false, workspace_path: restored_ui_state.workspace_path.clone(), no_sessions_confirm_open: false, @@ -512,6 +657,11 @@ async fn run_inner( limits_error: None, limits_notice: None, limits_enabled: true, + account_usage: None, + account_usage_updated_at: None, + account_usage_error: None, + account_usage_notice: None, + account_usage_enabled: true, read_sessions_dir: config.read_sessions_dir.clone(), read_browser, }; @@ -584,6 +734,16 @@ async fn run_inner( Ok(()) } +fn should_continue_scan_catch_up( + pending_files: usize, + indexed_files: usize, + processed_bytes: u64, + previous: Option<(usize, u64)>, +) -> bool { + pending_files > 0 + && previous.is_none_or(|(files, bytes)| indexed_files > files || processed_bytes > bytes) +} + fn clear_scan_cache_files(path: &Path) -> Result<()> { for suffix in ["", "-wal", "-shm"] { let mut candidate_os = path.as_os_str().to_os_string(); @@ -719,7 +879,59 @@ fn handle_input_event( if state.show_help || state.no_sessions_confirm_open { return Ok(InputOutcome::Continue(false)); } - if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + let wheel_direction = match mouse.kind { + MouseEventKind::ScrollUp => Some(true), + MouseEventKind::ScrollDown => Some(false), + _ => None, + }; + if let Some(older) = wheel_direction { + let changed = match state.active_screen { + ActiveScreen::Usage + if state + .usage_scroll_area + .is_some_and(|area| rect_contains(area, mouse.column, mouse.row)) => + { + let command = if older { + UsageCommand::ScrollOlder + } else { + UsageCommand::ScrollNewer + }; + handle_usage_command(state, command, usage_refresh_tx, limits_refresh_tx) + } + ActiveScreen::Activity + if state + .activity_scroll_area + .is_some_and(|area| rect_contains(area, mouse.column, mouse.row)) => + { + let command = if older { + ActivityCommand::ScrollOlder + } else { + ActivityCommand::ScrollNewer + }; + handle_activity_command(state, command, usage_refresh_tx, limits_refresh_tx) + } + ActiveScreen::ApiStat + if state + .api_stat_scroll_area + .is_some_and(|area| rect_contains(area, mouse.column, mouse.row)) => + { + let command = if older { + ApiStatCommand::ScrollOlder + } else { + ApiStatCommand::ScrollNewer + }; + handle_api_stat_command(state, command, limits_refresh_tx) + } + _ => false, + }; + if changed { + return Ok(InputOutcome::Continue(true)); + } + } + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left) + ) { if let Some(action) = ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) { if action == UiClickAction::ConfirmQuit @@ -793,6 +1005,13 @@ fn handle_input_event( handle_activity_command(state, command, usage_refresh_tx, limits_refresh_tx); Ok(InputOutcome::Continue(dirty)) } + ActiveScreen::ApiStat => { + let Some(command) = map_event_to_api_stat_cmd(event) else { + return Ok(InputOutcome::Continue(false)); + }; + let dirty = handle_api_stat_command(state, command, limits_refresh_tx); + Ok(InputOutcome::Continue(dirty)) + } ActiveScreen::LimitResets => { let Some(command) = map_event_to_limit_reset_cmd(event) else { return Ok(InputOutcome::Continue(false)); @@ -814,6 +1033,13 @@ fn ui_click_action_at(targets: &[UiHitTarget], column: u16, row: u16) -> Option< .map(|target| target.action) } +fn rect_contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { match action { UiClickAction::SetScreen(screen) => { @@ -834,11 +1060,85 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { UiClickAction::SetRange(range) => { let changed = state.range != range; state.range = range; + if changed { + state.usage_period_offset = 0; + } + changed + } + UiClickAction::SetUsageZone(zone) => { + let changed = state.usage_zone != zone; + state.usage_zone = zone; + if changed { + state.usage_period_offset = 0; + } changed } UiClickAction::SetOrientation(orientation) => { let changed = state.orientation != orientation; state.orientation = orientation; + if changed { + state.usage_period_offset = 0; + } + changed + } + UiClickAction::SetApiStatGrouping(grouping) => { + let changed = state.api_stat_grouping != grouping; + state.api_stat_grouping = grouping; + if changed { + state.api_stat_period_offset = 0; + } + changed + } + UiClickAction::SetApiStatGraph(graph) => { + let changed = state.api_stat_graph != graph; + state.api_stat_graph = graph; + if changed { + state.api_stat_period_offset = 0; + } + changed + } + UiClickAction::SetApiStatOrientation(orientation) => { + let changed = state.api_stat_orientation != orientation; + state.api_stat_orientation = orientation; + if changed { + state.api_stat_period_offset = 0; + } + changed + } + UiClickAction::ScrollUsageOlder => scroll_usage_periods(state, 1, true), + UiClickAction::ScrollUsageNewer => scroll_usage_periods(state, 1, false), + UiClickAction::SetUsageScrollOffset(offset) => { + let next = offset.min( + state + .usage_total_periods + .saturating_sub(state.usage_visible_periods), + ); + let changed = state.usage_period_offset != next; + state.usage_period_offset = next; + changed + } + UiClickAction::ScrollApiStatOlder => scroll_api_stat_periods(state, 1, true), + UiClickAction::ScrollApiStatNewer => scroll_api_stat_periods(state, 1, false), + UiClickAction::SetApiStatScrollOffset(offset) => { + let next = offset.min( + state + .api_stat_total_periods + .saturating_sub(state.api_stat_visible_periods), + ); + let changed = state.api_stat_period_offset != next; + state.api_stat_period_offset = next; + changed + } + UiClickAction::ScrollActivityOlder => scroll_activity_weeks(state, 1, true), + UiClickAction::ScrollActivityNewer => scroll_activity_weeks(state, 1, false), + UiClickAction::SetActivityScrollOffset(offset) => { + let next = offset.min( + state + .activity_total_weeks + .saturating_sub(state.activity_visible_weeks), + ); + let changed = state.activity_week_offset != next; + state.activity_week_offset = next; changed } UiClickAction::DecreaseProjects => { @@ -903,8 +1203,20 @@ fn map_event_to_usage_cmd(event: Event) -> Option { match (key.code, key.modifiers) { (KeyCode::Char('r'), _) | (KeyCode::F(5), _) => Some(UsageCommand::RefreshAll), (KeyCode::Tab, _) => Some(UsageCommand::ToggleMetric), - (KeyCode::Char('w'), _) => Some(UsageCommand::ToggleRange), + (KeyCode::Char('g'), _) + | (KeyCode::Char('G'), _) + | (KeyCode::Char('w'), _) + | (KeyCode::Char('W'), _) => Some(UsageCommand::ToggleRange), + (KeyCode::Char('z'), _) | (KeyCode::Char('Z'), _) | (KeyCode::F(6), _) => { + Some(UsageCommand::ToggleZone) + } (KeyCode::Char('f'), _) => Some(UsageCommand::ToggleOrientation), + (KeyCode::Left, _) | (KeyCode::Up, _) => Some(UsageCommand::ScrollOlder), + (KeyCode::Right, _) | (KeyCode::Down, _) => Some(UsageCommand::ScrollNewer), + (KeyCode::PageUp, _) => Some(UsageCommand::PageOlder), + (KeyCode::PageDown, _) => Some(UsageCommand::PageNewer), + (KeyCode::Home, _) => Some(UsageCommand::ScrollOldest), + (KeyCode::End, _) => Some(UsageCommand::ScrollNewest), (KeyCode::Char('?'), _) => Some(UsageCommand::ToggleHelp), (KeyCode::Enter, _) => Some(UsageCommand::ConfirmContinue), (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => { @@ -932,6 +1244,12 @@ fn map_event_to_activity_cmd(event: Event) -> Option { (KeyCode::Char('-'), _) | (KeyCode::Char('['), _) => { Some(ActivityCommand::DecreaseProjects) } + (KeyCode::Left, _) => Some(ActivityCommand::ScrollOlder), + (KeyCode::Right, _) => Some(ActivityCommand::ScrollNewer), + (KeyCode::PageUp, _) => Some(ActivityCommand::PageOlder), + (KeyCode::PageDown, _) => Some(ActivityCommand::PageNewer), + (KeyCode::Home, _) => Some(ActivityCommand::ScrollOldest), + (KeyCode::End, _) => Some(ActivityCommand::ScrollNewest), (KeyCode::Char('?'), _) => Some(ActivityCommand::ToggleHelp), _ => None, } @@ -958,6 +1276,30 @@ fn map_event_to_limit_reset_cmd(event: Event) -> Option { } } +fn map_event_to_api_stat_cmd(event: Event) -> Option { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => match (key.code, key.modifiers) { + (KeyCode::Char('r'), _) | (KeyCode::F(5), _) => Some(ApiStatCommand::Refresh), + (KeyCode::Char('g'), _) | (KeyCode::Char('G'), _) => { + Some(ApiStatCommand::ToggleGrouping) + } + (KeyCode::Char('b'), _) | (KeyCode::Char('B'), _) => Some(ApiStatCommand::ToggleGraph), + (KeyCode::Char('f'), _) | (KeyCode::Char('F'), _) => { + Some(ApiStatCommand::ToggleOrientation) + } + (KeyCode::Left, _) | (KeyCode::Up, _) => Some(ApiStatCommand::ScrollOlder), + (KeyCode::Right, _) | (KeyCode::Down, _) => Some(ApiStatCommand::ScrollNewer), + (KeyCode::PageUp, _) => Some(ApiStatCommand::PageOlder), + (KeyCode::PageDown, _) => Some(ApiStatCommand::PageNewer), + (KeyCode::Home, _) => Some(ApiStatCommand::ScrollOldest), + (KeyCode::End, _) => Some(ApiStatCommand::ScrollNewest), + (KeyCode::Char('?'), _) => Some(ApiStatCommand::ToggleHelp), + _ => None, + }, + _ => None, + } +} + fn handle_usage_command( state: &mut AppState, cmd: UsageCommand, @@ -989,10 +1331,13 @@ fn handle_usage_command( true } UsageCommand::ToggleRange => { - state.range = match state.range { - ChartRange::Week => ChartRange::Month, - ChartRange::Month => ChartRange::Week, - }; + state.range = state.range.toggled(); + state.usage_period_offset = 0; + true + } + UsageCommand::ToggleZone => { + state.usage_zone = state.usage_zone.toggled(); + state.usage_period_offset = 0; true } UsageCommand::ToggleOrientation => { @@ -1000,8 +1345,30 @@ fn handle_usage_command( ChartOrientation::Horizontal => ChartOrientation::Vertical, ChartOrientation::Vertical => ChartOrientation::Horizontal, }; + state.usage_period_offset = 0; true } + UsageCommand::ScrollOlder => scroll_usage_periods(state, 1, true), + UsageCommand::ScrollNewer => scroll_usage_periods(state, 1, false), + UsageCommand::PageOlder => { + scroll_usage_periods(state, state.usage_visible_periods.max(1), true) + } + UsageCommand::PageNewer => { + scroll_usage_periods(state, state.usage_visible_periods.max(1), false) + } + UsageCommand::ScrollOldest => { + let next = state + .usage_total_periods + .saturating_sub(state.usage_visible_periods); + let changed = state.usage_period_offset != next; + state.usage_period_offset = next; + changed + } + UsageCommand::ScrollNewest => { + let changed = state.usage_period_offset != 0; + state.usage_period_offset = 0; + changed + } UsageCommand::RefreshAll => { let _ = usage_refresh_tx.try_send(()); let _ = limits_refresh_tx.try_send(()); @@ -1010,6 +1377,34 @@ fn handle_usage_command( } } +fn scroll_usage_periods(state: &mut AppState, amount: usize, older: bool) -> bool { + let next = scrolled_period_offset( + state.usage_period_offset, + state.usage_total_periods, + state.usage_visible_periods, + amount, + older, + ); + let changed = next != state.usage_period_offset; + state.usage_period_offset = next; + changed +} + +fn scrolled_period_offset( + current: usize, + total: usize, + visible: usize, + amount: usize, + older: bool, +) -> usize { + let max_offset = total.saturating_sub(visible); + if older { + current.saturating_add(amount).min(max_offset) + } else { + current.saturating_sub(amount) + } +} + fn handle_activity_command( state: &mut AppState, cmd: ActivityCommand, @@ -1047,6 +1442,27 @@ fn handle_activity_command( state.activity_project_limit = next; changed } + ActivityCommand::ScrollOlder => scroll_activity_weeks(state, 1, true), + ActivityCommand::ScrollNewer => scroll_activity_weeks(state, 1, false), + ActivityCommand::PageOlder => { + scroll_activity_weeks(state, state.activity_visible_weeks.max(1), true) + } + ActivityCommand::PageNewer => { + scroll_activity_weeks(state, state.activity_visible_weeks.max(1), false) + } + ActivityCommand::ScrollOldest => { + let next = state + .activity_total_weeks + .saturating_sub(state.activity_visible_weeks); + let changed = next != state.activity_week_offset; + state.activity_week_offset = next; + changed + } + ActivityCommand::ScrollNewest => { + let changed = state.activity_week_offset != 0; + state.activity_week_offset = 0; + changed + } ActivityCommand::RefreshAll => { let _ = usage_refresh_tx.try_send(()); let _ = limits_refresh_tx.try_send(()); @@ -1055,6 +1471,23 @@ fn handle_activity_command( } } +fn scroll_activity_weeks(state: &mut AppState, amount: usize, older: bool) -> bool { + let max_offset = state + .activity_total_weeks + .saturating_sub(state.activity_visible_weeks); + let next = if older { + state + .activity_week_offset + .saturating_add(amount) + .min(max_offset) + } else { + state.activity_week_offset.saturating_sub(amount) + }; + let changed = next != state.activity_week_offset; + state.activity_week_offset = next; + changed +} + fn handle_limit_reset_command( state: &mut AppState, cmd: LimitResetCommand, @@ -1072,6 +1505,84 @@ fn handle_limit_reset_command( } } +fn handle_api_stat_command( + state: &mut AppState, + cmd: ApiStatCommand, + account_refresh_tx: &mpsc::Sender<()>, +) -> bool { + match cmd { + ApiStatCommand::ToggleHelp => { + state.show_help = !state.show_help; + true + } + ApiStatCommand::Refresh => { + let _ = account_refresh_tx.try_send(()); + true + } + ApiStatCommand::ToggleGrouping => { + if state.api_stat_graph == ApiStatGraph::Heat { + return false; + } + state.api_stat_grouping = state.api_stat_grouping.toggled(); + state.api_stat_period_offset = 0; + true + } + ApiStatCommand::ToggleGraph => { + state.api_stat_graph = match state.api_stat_graph { + ApiStatGraph::Bars => ApiStatGraph::Heat, + ApiStatGraph::Heat => ApiStatGraph::Bars, + }; + state.api_stat_period_offset = 0; + true + } + ApiStatCommand::ToggleOrientation => { + if state.api_stat_graph == ApiStatGraph::Heat { + return false; + } + state.api_stat_orientation = match state.api_stat_orientation { + ChartOrientation::Vertical => ChartOrientation::Horizontal, + ChartOrientation::Horizontal => ChartOrientation::Vertical, + }; + state.api_stat_period_offset = 0; + true + } + ApiStatCommand::ScrollOlder => scroll_api_stat_periods(state, 1, true), + ApiStatCommand::ScrollNewer => scroll_api_stat_periods(state, 1, false), + ApiStatCommand::PageOlder => { + scroll_api_stat_periods(state, state.api_stat_visible_periods.max(1), true) + } + ApiStatCommand::PageNewer => { + scroll_api_stat_periods(state, state.api_stat_visible_periods.max(1), false) + } + ApiStatCommand::ScrollOldest => { + let next = state + .api_stat_total_periods + .saturating_sub(state.api_stat_visible_periods); + let changed = state.api_stat_period_offset != next; + state.api_stat_period_offset = next; + changed + } + ApiStatCommand::ScrollNewest => { + let changed = state.api_stat_period_offset != 0; + state.api_stat_period_offset = 0; + changed + } + } +} + +fn scroll_api_stat_periods(state: &mut AppState, amount: usize, older: bool) -> bool { + let next = scrolled_period_offset( + state.api_stat_period_offset, + state.api_stat_total_periods, + state.api_stat_visible_periods, + amount, + older, + ); + let changed = next != state.api_stat_period_offset; + state.api_stat_period_offset = next; + changed +} + fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool { match evt { AppEvent::UsageUpdated(res) => { @@ -1093,14 +1604,19 @@ fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool { } true } - AppEvent::LimitsUnavailable { message, is_error } => { + AppEvent::AccountApiUnavailable { message, is_error } => { state.limits_enabled = false; + state.account_usage_enabled = false; if is_error { - state.limits_error = Some(message); + state.limits_error = Some(message.clone()); state.limits_notice = None; + state.account_usage_error = Some(message); + state.account_usage_notice = None; } else { state.limits_error = None; - state.limits_notice = Some(message); + state.limits_notice = Some(message.clone()); + state.account_usage_error = None; + state.account_usage_notice = Some(message); } true } @@ -1119,6 +1635,21 @@ fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool { } true } + AppEvent::AccountUsageUpdated(res) => { + match res { + Ok(usage) => { + state.account_usage_enabled = true; + state.account_usage = Some(usage); + state.account_usage_error = None; + state.account_usage_notice = None; + state.account_usage_updated_at = Some(Instant::now()); + } + Err(err) => { + state.account_usage_error = Some(err.to_string()); + } + } + true + } } } @@ -1144,11 +1675,31 @@ fn load_persisted_ui_state( state.range = range; } } + if let Some(zone_text) = store.global.usage_zone.as_deref() { + if let Some(zone) = usage_zone_from_store(zone_text) { + state.usage_zone = zone; + } + } if let Some(orientation_text) = store.global.orientation.as_deref() { if let Some(orientation) = chart_orientation_from_store(orientation_text) { state.orientation = orientation; } } + if let Some(grouping_text) = store.global.api_stat_grouping.as_deref() { + if let Some(grouping) = api_stat_grouping_from_store(grouping_text) { + state.api_stat_grouping = grouping; + } + } + if let Some(graph_text) = store.global.api_stat_graph.as_deref() { + if let Some(graph) = api_stat_graph_from_store(graph_text) { + state.api_stat_graph = graph; + } + } + if let Some(orientation_text) = store.global.api_stat_orientation.as_deref() { + if let Some(orientation) = chart_orientation_from_store(orientation_text) { + state.api_stat_orientation = orientation; + } + } if let Some(limit) = store.global.activity_project_limit { state.activity_project_limit = limit.clamp(MIN_ACTIVITY_PROJECT_LIMIT, MAX_ACTIVITY_PROJECT_LIMIT); @@ -1180,7 +1731,13 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul store.global.metric = Some(usage_metric_to_store(state.metric).to_string()); store.global.range = Some(chart_range_to_store(state.range).to_string()); + store.global.usage_zone = Some(usage_zone_to_store(state.usage_zone).to_string()); store.global.orientation = Some(chart_orientation_to_store(state.orientation).to_string()); + store.global.api_stat_grouping = + Some(api_stat_grouping_to_store(state.api_stat_grouping).to_string()); + store.global.api_stat_graph = Some(api_stat_graph_to_store(state.api_stat_graph).to_string()); + store.global.api_stat_orientation = + Some(chart_orientation_to_store(state.api_stat_orientation).to_string()); store.global.activity_project_limit = Some( state .activity_project_limit @@ -1251,6 +1808,7 @@ fn usage_metric_from_store(value: &str) -> Option { fn chart_range_to_store(range: ChartRange) -> &'static str { match range { + ChartRange::Day => "day", ChartRange::Week => "week", ChartRange::Month => "month", } @@ -1258,12 +1816,28 @@ fn chart_range_to_store(range: ChartRange) -> &'static str { fn chart_range_from_store(value: &str) -> Option { match value { + "day" => Some(ChartRange::Day), "week" => Some(ChartRange::Week), "month" => Some(ChartRange::Month), _ => None, } } +fn usage_zone_to_store(zone: UsageZone) -> &'static str { + match zone { + UsageZone::Local => "local", + UsageZone::Utc => "utc", + } +} + +fn usage_zone_from_store(value: &str) -> Option { + match value { + "local" => Some(UsageZone::Local), + "utc" => Some(UsageZone::Utc), + _ => None, + } +} + fn chart_orientation_to_store(orientation: ChartOrientation) -> &'static str { match orientation { ChartOrientation::Vertical => "vertical", @@ -1279,6 +1853,38 @@ fn chart_orientation_from_store(value: &str) -> Option { } } +fn api_stat_grouping_to_store(grouping: ApiStatGrouping) -> &'static str { + match grouping { + ApiStatGrouping::Day => "day", + ApiStatGrouping::Week => "week", + ApiStatGrouping::Month => "month", + } +} + +fn api_stat_grouping_from_store(value: &str) -> Option { + match value { + "day" => Some(ApiStatGrouping::Day), + "week" => Some(ApiStatGrouping::Week), + "month" => Some(ApiStatGrouping::Month), + _ => None, + } +} + +fn api_stat_graph_to_store(graph: ApiStatGraph) -> &'static str { + match graph { + ApiStatGraph::Bars => "bars", + ApiStatGraph::Heat => "heat", + } +} + +fn api_stat_graph_from_store(value: &str) -> Option { + match value { + "bars" => Some(ApiStatGraph::Bars), + "heat" => Some(ApiStatGraph::Heat), + _ => None, + } +} + fn unix_time_seconds() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1300,6 +1906,11 @@ impl AppState { let updated_at = self.limits_updated_at?; Some(crate::ui::format_updated_label(updated_at)) } + + pub(crate) fn account_usage_updated_label(&self) -> Option { + let updated_at = self.account_usage_updated_at?; + Some(crate::ui::format_updated_label(updated_at)) + } } #[cfg(test)] @@ -1328,6 +1939,39 @@ mod tests { assert_eq!(ui_click_action_at(&targets, 10, 7), None); } + #[test] + fn period_scrolling_clamps_at_oldest_and_newest() { + assert_eq!(scrolled_period_offset(0, 30, 10, 1, true), 1); + assert_eq!(scrolled_period_offset(19, 30, 10, 5, true), 20); + assert_eq!(scrolled_period_offset(3, 30, 10, 2, false), 1); + assert_eq!(scrolled_period_offset(1, 30, 10, 5, false), 0); + assert_eq!(scrolled_period_offset(4, 5, 10, 1, true), 0); + } + + #[test] + fn rapid_scan_catch_up_stops_when_pending_work_cannot_advance() { + assert!(should_continue_scan_catch_up(4, 10, 1_000, None)); + assert!(should_continue_scan_catch_up( + 3, + 11, + 1_000, + Some((10, 1_000)) + )); + assert!(should_continue_scan_catch_up( + 3, + 10, + 1_500, + Some((10, 1_000)) + )); + assert!(!should_continue_scan_catch_up( + 1, + 10, + 1_000, + Some((10, 1_000)) + )); + assert!(!should_continue_scan_catch_up(0, 10, 1_000, Some((9, 900)))); + } + fn make_temp_dir(prefix: &str) -> PathBuf { let unique = format!( "{}-{}-{}", @@ -1345,13 +1989,17 @@ mod tests { } #[test] - fn screen_cycle_includes_limit_resets() { + fn screen_cycle_includes_api_stats_and_limit_resets() { assert_eq!( next_active_screen(ActiveScreen::Usage), ActiveScreen::Activity ); assert_eq!( next_active_screen(ActiveScreen::Activity), + ActiveScreen::ApiStat + ); + assert_eq!( + next_active_screen(ActiveScreen::ApiStat), ActiveScreen::LimitResets ); assert_eq!( @@ -1422,6 +2070,38 @@ mod tests { let _ = std::fs::remove_dir_all(comon_home); } + #[test] + fn api_stat_controls_round_trip_through_state_store() { + let comon_home = make_temp_dir("api-stat-controls"); + let mut state = PersistedUiState::default_for_workspace(None); + state.api_stat_grouping = ApiStatGrouping::Month; + state.api_stat_graph = ApiStatGraph::Heat; + state.api_stat_orientation = ChartOrientation::Horizontal; + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.api_stat_grouping, ApiStatGrouping::Month); + assert_eq!(loaded.api_stat_graph, ApiStatGraph::Heat); + assert_eq!(loaded.api_stat_orientation, ChartOrientation::Horizontal); + + let _ = std::fs::remove_dir_all(comon_home); + } + + #[test] + fn usage_grouping_and_zone_round_trip_through_state_store() { + let comon_home = make_temp_dir("usage-zone-controls"); + let mut state = PersistedUiState::default_for_workspace(None); + state.range = ChartRange::Month; + state.usage_zone = UsageZone::Utc; + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.range, ChartRange::Month); + assert_eq!(loaded.usage_zone, UsageZone::Utc); + + let _ = std::fs::remove_dir_all(comon_home); + } + #[test] fn quit_confirmation_preference_round_trips_through_state_store() { let comon_home = make_temp_dir("quit-confirmation-preference"); diff --git a/src/codex_rpc/mod.rs b/src/codex_rpc/mod.rs index 3802323..5273442 100644 --- a/src/codex_rpc/mod.rs +++ b/src/codex_rpc/mod.rs @@ -78,6 +78,28 @@ pub struct AccountRateLimits { pub reset_credits: Option>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountUsageSummary { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DailyUsageBucket { + pub start_date: String, + pub tokens: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountUsage { + pub summary: AccountUsageSummary, + /// `None` means the backend did not return the optional daily activity series. + pub daily_usage_buckets: Option>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppServerCommand { program: PathBuf, @@ -358,6 +380,17 @@ impl CodexRpc { parse_account_rate_limits_result(&result) } + pub async fn read_account_usage(&self) -> Result { + let value = self.send_request("account/usage/read", Value::Null).await?; + + if let Some(err) = value.get("error") { + return Err(anyhow!("account/usage/read error: {err}")); + } + + let result = value.get("result").cloned().unwrap_or(Value::Null); + parse_account_usage_result(&result) + } + pub async fn recv_notification(&self) -> Option { self.notifications.lock().await.recv().await } @@ -636,6 +669,53 @@ fn parse_account_rate_limits_result(result: &Value) -> Result Ok(limits) } +fn parse_account_usage_result(result: &Value) -> Result { + let result = result + .as_object() + .ok_or_else(|| anyhow!("account usage result missing"))?; + let summary = result.get("summary").and_then(Value::as_object); + let read_summary_u64 = |camel: &str, snake: &str| { + summary + .and_then(|value| value.get(camel).or_else(|| value.get(snake))) + .and_then(as_u64_maybe_float) + }; + + let mut daily_usage_buckets = result + .get("dailyUsageBuckets") + .or_else(|| result.get("daily_usage_buckets")) + .and_then(Value::as_array) + .map(|buckets| { + buckets + .iter() + .filter_map(parse_daily_usage_bucket) + .collect::>() + }); + if let Some(buckets) = daily_usage_buckets.as_mut() { + buckets.sort_by(|left, right| left.start_date.cmp(&right.start_date)); + } + + Ok(AccountUsage { + summary: AccountUsageSummary { + lifetime_tokens: read_summary_u64("lifetimeTokens", "lifetime_tokens"), + peak_daily_tokens: read_summary_u64("peakDailyTokens", "peak_daily_tokens"), + longest_running_turn_sec: read_summary_u64( + "longestRunningTurnSec", + "longest_running_turn_sec", + ), + current_streak_days: read_summary_u64("currentStreakDays", "current_streak_days"), + longest_streak_days: read_summary_u64("longestStreakDays", "longest_streak_days"), + }, + daily_usage_buckets, + }) +} + +fn parse_daily_usage_bucket(value: &Value) -> Option { + let value = value.as_object()?; + let start_date = read_string(value.get("startDate").or_else(|| value.get("start_date")))?; + let tokens = value.get("tokens").and_then(as_u64_maybe_float)?; + Some(DailyUsageBucket { start_date, tokens }) +} + fn parse_rate_limits(value: &Value) -> Result { let snapshot = parse_rate_limit_snapshot(value).ok_or_else(|| anyhow!("rate limits missing"))?; @@ -785,6 +865,19 @@ fn as_i64_maybe_float(value: &Value) -> Option { .or_else(|| value.as_str().and_then(|s| s.parse::().ok())) } +fn as_u64_maybe_float(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|number| u64::try_from(number).ok())) + .or_else(|| value.as_str().and_then(|number| number.parse::().ok())) + .or_else(|| { + value.as_f64().and_then(|number| { + (number.is_finite() && number >= 0.0 && number <= u64::MAX as f64) + .then(|| number.round() as u64) + }) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -891,6 +984,50 @@ mod tests { ); } + #[test] + fn parse_account_usage_result_accepts_summary_and_sorts_daily_buckets() { + let value = json!({ + "summary": { + "lifetimeTokens": 8_206_591_409_u64, + "peakDailyTokens": 773_124_581_u64, + "longestRunningTurnSec": 42_168, + "currentStreakDays": 2, + "longestStreakDays": 21 + }, + "dailyUsageBuckets": [ + { "startDate": "2026-07-23", "tokens": 97_465_847 }, + { "startDate": "2026-07-22", "tokens": 215_392_194 } + ] + }); + + let usage = parse_account_usage_result(&value).expect("parse account usage"); + assert_eq!(usage.summary.lifetime_tokens, Some(8_206_591_409)); + assert_eq!(usage.summary.peak_daily_tokens, Some(773_124_581)); + assert_eq!(usage.summary.longest_running_turn_sec, Some(42_168)); + assert_eq!(usage.summary.current_streak_days, Some(2)); + assert_eq!(usage.summary.longest_streak_days, Some(21)); + let buckets = usage.daily_usage_buckets.expect("daily buckets"); + assert_eq!(buckets.len(), 2); + assert_eq!(buckets[0].start_date, "2026-07-22"); + assert_eq!(buckets[1].tokens, 97_465_847); + } + + #[test] + fn parse_account_usage_result_preserves_nullable_optional_fields() { + let value = json!({ + "summary": { + "lifetimeTokens": null, + "peak_daily_tokens": 1234 + }, + "dailyUsageBuckets": null + }); + + let usage = parse_account_usage_result(&value).expect("parse account usage"); + assert_eq!(usage.summary.lifetime_tokens, None); + assert_eq!(usage.summary.peak_daily_tokens, Some(1234)); + assert_eq!(usage.daily_usage_buckets, None); + } + #[test] fn recognizes_account_rate_limits_updated_notification() { let v = json!({ diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2bc30f9..2da7dbe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,11 +1,17 @@ -use crate::app::{ActiveScreen, AppState, ChartOrientation, UiClickAction, UiHitTarget}; +use crate::app::{ + ActiveScreen, ApiStatGraph, ApiStatGrouping, AppState, ChartOrientation, UiClickAction, + UiHitTarget, +}; use crate::locale::{DisplayFormatter, DisplayStyle}; use crate::usage::{ format_compact_kmb, format_count, format_duration_compact, format_tokens_compact, - format_tokens_overview, ChartRange, ProjectActivity, UsageMetric, ACTIVITY_TIMELINE_WEEKS, + format_tokens_overview, ChartRange, ProjectActivity, UsageDay, UsageMetric, UsageZone, + ACTIVITY_TIMELINE_WEEKS, }; use anyhow::Result; -use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, TimeZone, Weekday}; +use chrono::{ + Datelike, Duration as ChronoDuration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc, Weekday, +}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -19,6 +25,7 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, Frame, Terminal, }; +use std::collections::BTreeMap; use std::io::{self, Stdout}; use std::time::Instant; use unicode_width::UnicodeWidthStr; @@ -75,6 +82,9 @@ pub fn format_updated_label(updated_at: Instant) -> String { pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { state.ui_hit_targets.clear(); + state.usage_scroll_area = None; + state.activity_scroll_area = None; + state.api_stat_scroll_area = None; let area = frame.area(); let (navigation, navigation_targets) = navigation_title(area, state.active_screen); state.ui_hit_targets.extend(navigation_targets); @@ -86,7 +96,7 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { .borders(Borders::ALL) .border_type(BorderType::Plain) .title(Span::styled( - format!(" comon :: {} ", env!("CARGO_PKG_VERSION")), + app_title(state.active_screen), Style::default().add_modifier(Modifier::BOLD), )) .title_top(navigation.right_aligned()) @@ -136,6 +146,25 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { render_help_overlay(frame, area, state.active_screen); } } + ActiveScreen::ApiStat => { + let footer_height = footer_height(inner.width, state); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(footer_height), + ]) + .split(inner); + + render_api_stat_header(frame, chunks[0], state); + render_api_stats(frame, chunks[1], state); + render_footer(frame, chunks[2], state); + + if state.show_help { + render_help_overlay(frame, area, state.active_screen); + } + } ActiveScreen::LimitResets => { let footer_height = footer_height(inner.width, state); let chunks = Layout::default() @@ -169,6 +198,14 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { } } +fn app_title(active_screen: ActiveScreen) -> String { + if active_screen == ActiveScreen::ApiStat { + format!(" comon :: {} :: SOURCE OPENAI ", env!("CARGO_PKG_VERSION")) + } else { + format!(" comon :: {} ", env!("CARGO_PKG_VERSION")) + } +} + fn usage_screen_layout(area: Rect, footer_height: u16) -> [Rect; 3] { let chunks = Layout::default() .direction(Direction::Vertical) @@ -198,6 +235,10 @@ fn navigation_title(area: Rect, active_screen: ActiveScreen) -> (Line<'static>, " ACTIVITY ", Some(UiClickAction::SetScreen(ActiveScreen::Activity)), ), + ( + " APISTAT ", + Some(UiClickAction::SetScreen(ActiveScreen::ApiStat)), + ), ( " LIMITS ", Some(UiClickAction::SetScreen(ActiveScreen::LimitResets)), @@ -207,8 +248,7 @@ fn navigation_title(area: Rect, active_screen: ActiveScreen) -> (Line<'static>, Some(UiClickAction::SetScreen(ActiveScreen::Read)), ), ]; - let app_title_width = - UnicodeWidthStr::width(format!(" comon :: {} ", env!("CARGO_PKG_VERSION")).as_str()); + let app_title_width = UnicodeWidthStr::width(app_title(active_screen).as_str()); let navigation_width = segments .iter() .map(|(text, _)| UnicodeWidthStr::width(*text)) @@ -225,6 +265,7 @@ fn navigation_title(area: Rect, active_screen: ActiveScreen) -> (Line<'static>, Span::raw(" "), pill("USAGE", active_screen == ActiveScreen::Usage), pill("ACTIVITY", active_screen == ActiveScreen::Activity), + pill("APISTAT", active_screen == ActiveScreen::ApiStat), pill("LIMITS", active_screen == ActiveScreen::LimitResets), pill("HISTORY", active_screen == ActiveScreen::Read), ]); @@ -263,10 +304,10 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let line_area = usage_header_line_area(area); let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(18)]) + .constraints([Constraint::Min(20), Constraint::Length(40)]) .split(line_area); - let title = "USAGE_SNAPSHOT"; + let title = "USAGE_SNAPSHOT :: LOCAL"; let left = Paragraph::new(Line::from(Span::styled( title, Style::default().add_modifier(Modifier::BOLD), @@ -274,10 +315,7 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .alignment(Alignment::Left); frame.render_widget(left, row[0]); - let updated = state - .usage_updated_label() - .or_else(|| state.limits_updated_label()) - .unwrap_or_else(|| "Updated --".to_string()); + let updated = usage_scan_status_label(state).unwrap_or_else(|| "Updated --".to_string()); let right = Paragraph::new(updated).alignment(Alignment::Right); frame.render_widget(right, row[1]); @@ -295,7 +333,7 @@ fn usage_header_line_area(area: Rect) -> Rect { fn render_activity_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(18)]) + .constraints([Constraint::Min(20), Constraint::Length(40)]) .split(area); let left = Paragraph::new(Line::from(Span::styled( @@ -305,13 +343,29 @@ fn render_activity_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .alignment(Alignment::Left); frame.render_widget(left, row[0]); - let updated = state - .usage_updated_label() - .unwrap_or_else(|| "Updated --".to_string()); + let updated = usage_scan_status_label(state).unwrap_or_else(|| "Updated --".to_string()); let right = Paragraph::new(updated).alignment(Alignment::Right); frame.render_widget(right, row[1]); } +fn usage_scan_status_label(state: &AppState) -> Option { + let updated = state + .usage_updated_label() + .or_else(|| state.limits_updated_label())?; + let Some(snapshot) = state.usage.as_ref() else { + return Some(updated); + }; + let status = if snapshot.scan_pending_files == 0 { + "COMPLETE" + } else { + "PARTIAL" + }; + Some(format!( + "{updated} | {status} {}/{}", + snapshot.scan_indexed_files, snapshot.scan_total_files + )) +} + fn render_limit_resets_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let row = Layout::default() .direction(Direction::Horizontal) @@ -333,215 +387,1733 @@ fn render_limit_resets_header(frame: &mut Frame<'_>, area: Rect, state: &AppStat frame.render_widget(right, row[1]); } -fn footer_hint(screen: ActiveScreen) -> &'static str { - match screen { - ActiveScreen::Usage => { - "Usage: Statistic [tab] (tokens/time/runs), Timeframe [w] (week/month), Layout [f] (horizontal/vertical), Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" - } - ActiveScreen::Activity => { - "Activity: Statistic [tab] (tokens/time/runs), Projects [+/-], Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" - } - ActiveScreen::LimitResets => { - "Limit resets: Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" - } - ActiveScreen::Read => "", - } -} +fn render_api_stat_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Length(18)]) + .split(area); -fn footer_error(state: &AppState) -> String { - let err = state - .usage_error - .as_deref() - .or(state.limits_error.as_deref()) - .unwrap_or(""); - if err.is_empty() { - String::new() - } else { - truncate_middle(err, 80) - } + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "CODEX_ACCOUNT_STATS", + Style::default().add_modifier(Modifier::BOLD), + ))), + row[0], + ); + + let updated = state + .account_usage_updated_label() + .unwrap_or_else(|| "Updated --".to_string()); + frame.render_widget(Paragraph::new(updated).alignment(Alignment::Right), row[1]); } -fn footer_text(state: &AppState) -> String { - let hint = footer_hint(state.active_screen); - let style = format!("Style [n]: {}", state.formatter().style_label()); - let err = footer_error(state); - if err.is_empty() { - format!("{hint} {style}") +fn render_api_stats(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + let unavailable_message = if !state.account_usage_enabled { + state + .account_usage_error + .as_deref() + .or(state.account_usage_notice.as_deref()) + } else if state.account_usage.is_none() { + state.account_usage_error.as_deref() } else { - format!("{hint} {style} {err}") + None + }; + if let Some(message) = unavailable_message { + render_api_stat_message(frame, area, message); + return; } -} - -fn footer_height(width: u16, state: &AppState) -> u16 { - wrapped_line_count(&footer_text(state), width).max(1) -} - -fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { - let err = footer_error(state); - let style = format!("Style [n]: {}", state.formatter().style_label()); - let line = Line::from(vec![ - Span::styled( - footer_hint(state.active_screen), - Style::default().fg(Color::Gray), - ), - Span::raw(" "), - Span::styled(style, Style::default().fg(Color::Gray)), - Span::raw(if err.is_empty() { "" } else { " " }), - Span::styled(err, Style::default().fg(Color::Red)), - ]); - - frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), area); -} - -fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { - let cards_height = usage_cards_height(state, area.width); - let reset_summary = reset_summary_text(state); - let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); - let chunks = usage_layout(area, controls_height, cards_height); - render_usage_controls(frame, chunks[0], state, reset_summary.as_deref()); - render_usage_cards(frame, chunks[1], state); - render_usage_chart(frame, chunks[2], state); - render_top_models(frame, chunks[3], state); -} - -fn usage_layout(area: Rect, controls_height: u16, cards_height: u16) -> [Rect; 4] { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(controls_height), - Constraint::Length(cards_height), - // Cards must keep their measured height. `Min` has higher priority than `Length` - // in ratatui, so a short terminal would otherwise collapse the cards to borders in - // order to preserve the chart's minimum height. Let the chart consume the remaining - // space and degrade first instead. - Constraint::Fill(1), - Constraint::Length(3), - ]) - .split(area); - [chunks[0], chunks[1], chunks[2], chunks[3]] -} + let Some(account_usage) = state.account_usage.clone() else { + render_api_stat_message(frame, area, "Loading account statistics..."); + return; + }; -fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { - let cards_height = usage_cards_height(state, area.width); - let reset_summary = reset_summary_text(state); - let controls_height = activity_controls_height(reset_summary.as_deref(), area.width); + let summary_height = if area.width >= 110 { 5 } else { 10 }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(controls_height), - Constraint::Length(cards_height), - Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(summary_height), + Constraint::Min(0), ]) .split(area); - render_activity_controls(frame, chunks[0], state, reset_summary.as_deref()); - render_usage_cards(frame, chunks[1], state); - render_activity_heatmaps(frame, chunks[2], state); -} - -fn render_activity_controls( - frame: &mut Frame<'_>, - area: Rect, - state: &mut AppState, - reset_summary: Option<&str>, -) { - let reset_height = reset_summary_height(reset_summary, area.width); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(reset_height)]) - .split(area); - - let workspace_label = state - .workspace_path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "All workspaces".to_string()); - - let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); - let time = pill("TIME", state.metric == UsageMetric::Time); - let runs = pill("RUNS", state.metric == UsageMetric::Runs); - let project_count = state.activity_project_limit.to_string(); - - render_flat_activity_controls( - frame, - chunks[0], - state, - &workspace_label, - vec![tokens, time, runs], - project_count, + render_api_stat_controls(frame, chunks[0], state); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("SOURCE ", Style::default().fg(Color::Gray)), + Span::styled( + "CODEX APP SERVER :: account/usage/read :: ACCOUNT-WIDE / UTC", + Style::default().fg(Color::Cyan), + ), + ])), + chunks[1], ); - - if let Some(text) = reset_summary { - render_reset_summary(frame, chunks[1], text); - } + render_api_stat_summary(frame, chunks[2], &account_usage, state.formatter()); + render_api_stat_chart(frame, chunks[3], &account_usage, state); } -fn render_flat_activity_controls( - frame: &mut Frame<'_>, - area: Rect, - state: &mut AppState, - workspace_label: &str, - metric_pills: Vec>, - project_count: String, -) { - let row = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Min(0)]) - .split(area); - render_workspace_label(frame, row[0], workspace_label); +fn render_api_stat_controls(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + let bars = pill("BARS", state.api_stat_graph == ApiStatGraph::Bars); + let heat = pill("HEAT", state.api_stat_graph == ApiStatGraph::Heat); + let day = pill("DAY", state.api_stat_grouping == ApiStatGrouping::Day); + let week = pill("WEEK", state.api_stat_grouping == ApiStatGrouping::Week); + let month = pill("MONTH", state.api_stat_grouping == ApiStatGrouping::Month); + let vert = pill( + "VERT", + state.api_stat_orientation == ChartOrientation::Vertical, + ); + let horz = pill( + "HORZ", + state.api_stat_orientation == ChartOrientation::Horizontal, + ); + let classic = pill("CLASS", state.display_style == DisplayStyle::Classic); + let system_compact = pill("SCOMP", state.display_style == DisplayStyle::SystemCompact); + let system_full = pill("SFULL", state.display_style == DisplayStyle::SystemFull); - register_right_aligned_targets( - state, - row[1], - &[ - (" VIEW ", None), + let mut segments = vec![ + (" VIEW ", None), + ( + " BARS ", + Some(UiClickAction::SetApiStatGraph(ApiStatGraph::Bars)), + ), + ( + " HEAT ", + Some(UiClickAction::SetApiStatGraph(ApiStatGraph::Heat)), + ), + (" GRAPH ", None), + ( + " DAY ", + Some(UiClickAction::SetApiStatGrouping(ApiStatGrouping::Day)), + ), + ( + " WEEK ", + Some(UiClickAction::SetApiStatGrouping(ApiStatGrouping::Week)), + ), + ( + " MONTH ", + Some(UiClickAction::SetApiStatGrouping(ApiStatGrouping::Month)), + ), + ]; + let mut spans = vec![control_group_label("VIEW"), bars, heat]; + spans.push(control_group_label("GRAPH")); + spans.extend([day, week, month]); + if state.api_stat_graph == ApiStatGraph::Bars { + segments.extend([ + (" BARS ", None), ( - " TOKENS ", - Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + " VERT ", + Some(UiClickAction::SetApiStatOrientation( + ChartOrientation::Vertical, + )), ), - (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), - (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), - (" PROJECTS ", None), - (" + ", Some(UiClickAction::IncreaseProjects)), - (&project_count, None), - (" -", Some(UiClickAction::DecreaseProjects)), - ], - ); + ( + " HORZ ", + Some(UiClickAction::SetApiStatOrientation( + ChartOrientation::Horizontal, + )), + ), + ]); + spans.push(control_group_label("BARS")); + spans.extend([vert, horz]); + } + segments.extend([ + (" ZONE ", None), + (" UTC ", None), + (" STYLE ", None), + ( + " CLASS ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::Classic)), + ), + ( + " SCOMP ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemCompact)), + ), + ( + " SFULL ", + Some(UiClickAction::SetDisplayStyle(DisplayStyle::SystemFull)), + ), + ]); + spans.push(control_group_label("ZONE")); + spans.push(pill("UTC", true)); + spans.push(control_group_label("STYLE")); + spans.extend([classic, system_compact, system_full]); - let mut spans = vec![control_group_label("VIEW")]; - spans.extend(metric_pills); - spans.push(control_group_label("PROJECTS")); - spans.extend(activity_project_control_spans(project_count)); + register_right_aligned_targets(state, area, &segments); frame.render_widget( Paragraph::new(Line::from(spans)).alignment(Alignment::Right), - row[1], + area, ); } -fn activity_project_control_spans(project_count: String) -> [Span<'static>; 3] { - [ - pill("+", false), - Span::styled(project_count, Style::default().add_modifier(Modifier::BOLD)), - Span::styled(" -", Style::default().fg(Color::Gray)), - ] +fn render_api_stat_message(frame: &mut Frame<'_>, area: Rect, message: &str) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .padding(Padding { + left: 2, + right: 1, + top: 1, + bottom: 0, + }) + .title(Span::styled( + " CODEX_ACCOUNT ", + Style::default().fg(Color::Gray), + )); + let text = Text::from(vec![ + Line::from(message.to_string()), + Line::from(""), + Line::from(Span::styled( + "Requires ChatGPT-backed Codex authentication; API-key-only auth is not supported.", + Style::default().fg(Color::Gray), + )), + ]); + frame.render_widget( + Paragraph::new(text).block(block).wrap(Wrap { trim: true }), + area, + ); +} + +fn render_api_stat_summary( + frame: &mut Frame<'_>, + area: Rect, + usage: &crate::codex_rpc::AccountUsage, + formatter: DisplayFormatter<'_>, +) { + let summary = &usage.summary; + let cards = [ + ( + "LIFETIME", + format_optional_account_tokens(summary.lifetime_tokens, formatter), + "tokens", + ), + ( + "PEAK_DAY", + format_optional_account_tokens(summary.peak_daily_tokens, formatter), + "tokens", + ), + ( + "STREAK", + format_optional_days(summary.current_streak_days, formatter), + "current", + ), + ( + "BEST_STREAK", + format_optional_days(summary.longest_streak_days, formatter), + "longest", + ), + ( + "LONGEST_TURN", + summary + .longest_running_turn_sec + .map(format_account_duration) + .unwrap_or_else(|| "--".to_string()), + "elapsed", + ), + ]; + + if area.width >= 110 { + render_api_stat_card_row(frame, area, &cards); + } else { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(area); + render_api_stat_card_row(frame, rows[0], &cards[..3]); + render_api_stat_card_row(frame, rows[1], &cards[3..]); + } +} + +fn render_api_stat_card_row(frame: &mut Frame<'_>, area: Rect, cards: &[(&str, String, &str)]) { + if cards.is_empty() { + return; + } + let constraints = vec![Constraint::Ratio(1, cards.len() as u32); cards.len()]; + let areas = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(area); + + for (index, (title, value, caption)) in cards.iter().enumerate() { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .padding(Padding { + left: 1, + right: 1, + top: 0, + bottom: 0, + }) + .title(Span::styled( + format!(" {title} "), + Style::default().fg(Color::Gray), + )); + let text = Text::from(vec![ + Line::from(Span::styled( + value.clone(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + (*caption).to_string(), + Style::default().fg(Color::Gray), + )), + ]); + frame.render_widget(Paragraph::new(text).block(block), areas[index]); + } +} + +fn format_optional_account_tokens(value: Option, formatter: DisplayFormatter<'_>) -> String { + let Some(value) = value else { + return "--".to_string(); + }; + match formatter.style() { + DisplayStyle::SystemCompact => format_compact_kmb(value, 16, formatter), + DisplayStyle::Classic | DisplayStyle::SystemFull => formatter.format_u64(value), + } +} + +fn format_optional_days(value: Option, formatter: DisplayFormatter<'_>) -> String { + value + .map(|value| format!("{} days", formatter.format_u64(value))) + .unwrap_or_else(|| "--".to_string()) +} + +fn format_account_duration(mut seconds: u64) -> String { + let days = seconds / 86_400; + seconds %= 86_400; + let hours = seconds / 3_600; + seconds %= 3_600; + let minutes = seconds / 60; + seconds %= 60; + + if days > 0 { + format!("{days}d {hours}h {minutes}m") + } else if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ApiStatPoint { + start: NaiveDate, + end: NaiveDate, + tokens: u64, + active_days: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChartScrollbarOwner { + Usage, + ApiStat, + Activity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChartScrollbarAxis { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ChartScrollbarViewport { + total: usize, + visible: usize, + offset_from_newest: usize, +} + +fn reserve_chart_scrollbar( + area: Rect, + axis: ChartScrollbarAxis, + show: bool, +) -> (Rect, Option) { + if !show { + return (area, None); + } + match axis { + ChartScrollbarAxis::Vertical if area.width > 2 && area.height >= 3 => ( + Rect::new(area.x, area.y, area.width - 2, area.height), + Some(Rect::new( + area.x.saturating_add(area.width - 1), + area.y, + 1, + area.height, + )), + ), + ChartScrollbarAxis::Horizontal if area.height > 2 && area.width >= 3 => ( + Rect::new(area.x, area.y, area.width, area.height - 2), + Some(Rect::new( + area.x, + area.y.saturating_add(area.height - 1), + area.width, + 1, + )), + ), + _ => (area, None), + } +} + +fn render_chart_scrollbar( + frame: &mut Frame<'_>, + area: Rect, + axis: ChartScrollbarAxis, + viewport: ChartScrollbarViewport, + owner: ChartScrollbarOwner, + state: &mut AppState, +) { + let ChartScrollbarViewport { + total, + visible, + offset_from_newest, + } = viewport; + if total <= visible || visible == 0 { + return; + } + let length = match axis { + ChartScrollbarAxis::Horizontal => area.width, + ChartScrollbarAxis::Vertical => area.height, + }; + if length < 3 { + return; + } + + let max_offset = total.saturating_sub(visible); + let track_length = usize::from(length.saturating_sub(2)); + let thumb_length = track_length + .saturating_mul(visible) + .saturating_add(total.saturating_sub(1)) + .checked_div(total.max(1)) + .unwrap_or(1) + .clamp(1, track_length); + let thumb_travel = track_length.saturating_sub(thumb_length); + let window_start = max_offset.saturating_sub(offset_from_newest.min(max_offset)); + let thumb_start = window_start + .saturating_mul(thumb_travel) + .checked_div(max_offset) + .unwrap_or(0); + + let (older_char, newer_char) = match axis { + ChartScrollbarAxis::Horizontal => ('<', '>'), + ChartScrollbarAxis::Vertical => ('^', 'v'), + }; + let point_at = |position: u16| match axis { + ChartScrollbarAxis::Horizontal => (area.x.saturating_add(position), area.y), + ChartScrollbarAxis::Vertical => (area.x, area.y.saturating_add(position)), + }; + let rect_at = |position: u16| { + let (x, y) = point_at(position); + Rect::new(x, y, 1, 1) + }; + let (older_action, newer_action) = match owner { + ChartScrollbarOwner::Usage => ( + UiClickAction::ScrollUsageOlder, + UiClickAction::ScrollUsageNewer, + ), + ChartScrollbarOwner::ApiStat => ( + UiClickAction::ScrollApiStatOlder, + UiClickAction::ScrollApiStatNewer, + ), + ChartScrollbarOwner::Activity => ( + UiClickAction::ScrollActivityOlder, + UiClickAction::ScrollActivityNewer, + ), + }; + state.ui_hit_targets.push(UiHitTarget { + area: rect_at(0), + action: older_action, + }); + state.ui_hit_targets.push(UiHitTarget { + area: rect_at(length - 1), + action: newer_action, + }); + + let buf = frame.buffer_mut(); + for (position, ch, style) in [ + ( + 0, + older_char, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ( + length - 1, + newer_char, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ] { + let (x, y) = point_at(position); + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_char(ch).set_style(style); + } + } + + for index in 0..track_length { + let position = index as u16 + 1; + let in_thumb = index >= thumb_start && index < thumb_start.saturating_add(thumb_length); + let (x, y) = point_at(position); + if let Some(cell) = buf.cell_mut((x, y)) { + let (ch, style) = chart_scrollbar_cell(in_thumb); + cell.set_char(ch).set_style(style); + } + let logical_start = if track_length <= 1 { + 0 + } else { + index.saturating_mul(max_offset) / track_length.saturating_sub(1) + }; + let offset = max_offset.saturating_sub(logical_start); + let action = match owner { + ChartScrollbarOwner::Usage => UiClickAction::SetUsageScrollOffset(offset), + ChartScrollbarOwner::ApiStat => UiClickAction::SetApiStatScrollOffset(offset), + ChartScrollbarOwner::Activity => UiClickAction::SetActivityScrollOffset(offset), + }; + state.ui_hit_targets.push(UiHitTarget { + area: rect_at(position), + action, + }); + } +} + +fn chart_scrollbar_cell(in_thumb: bool) -> (char, Style) { + if !in_thumb { + return ('.', Style::default().fg(Color::DarkGray)); + } + (' ', Style::default().bg(Color::White)) +} + +fn viewport_bounds( + total: usize, + visible_capacity: usize, + offset_from_newest: usize, +) -> (usize, usize) { + let visible = total.min(visible_capacity.max(1)); + let max_offset = total.saturating_sub(visible); + let offset = offset_from_newest.min(max_offset); + let start = max_offset.saturating_sub(offset); + (start, start.saturating_add(visible).min(total)) +} + +fn viewport_label( + total: usize, + start: usize, + end: usize, + formatter: DisplayFormatter<'_>, +) -> String { + if total == 0 || (start == 0 && end == total) { + return formatter.format_usize(total); + } + let older = if start > 0 { "<" } else { " " }; + let newer = if end < total { ">" } else { " " }; + format!( + "{older} {}-{} / {} {newer}", + formatter.format_usize(start.saturating_add(1)), + formatter.format_usize(end), + formatter.format_usize(total) + ) +} + +fn horizontal_bar_height(total: usize, height: u16) -> u16 { + if total == 0 { + return 1; + } + if total <= usize::from(height / 3) { + 3 + } else if total <= usize::from(height / 2) { + 2 + } else { + 1 + } +} + +fn usage_horizontal_bar_height(total: usize, height: u16) -> u16 { + if total == 0 { + return 1; + } + let max_per_bar = height / (total as u16).max(1); + if max_per_bar >= 5 { + 5 + } else if max_per_bar >= 4 { + 4 + } else if max_per_bar >= 3 { + 3 + } else { + 1 + } +} + +fn update_api_stat_viewport( + state: &mut AppState, + area: Rect, + total: usize, + visible_capacity: usize, +) -> (usize, usize) { + let (start, end) = viewport_bounds(total, visible_capacity, state.api_stat_period_offset); + state.api_stat_period_offset = total.saturating_sub(end); + state.api_stat_total_periods = total; + state.api_stat_visible_periods = end.saturating_sub(start); + state.api_stat_scroll_area = Some(area); + (start, end) +} + +fn update_usage_viewport( + state: &mut AppState, + area: Rect, + total: usize, + visible_capacity: usize, +) -> (usize, usize) { + let (start, end) = viewport_bounds(total, visible_capacity, state.usage_period_offset); + state.usage_period_offset = total.saturating_sub(end); + state.usage_total_periods = total; + state.usage_visible_periods = end.saturating_sub(start); + state.usage_scroll_area = Some(area); + (start, end) +} + +fn render_api_stat_chart( + frame: &mut Frame<'_>, + area: Rect, + usage: &crate::codex_rpc::AccountUsage, + state: &mut AppState, +) { + match state.api_stat_graph { + ApiStatGraph::Heat => render_api_stat_heatmap(frame, area, usage, state), + ApiStatGraph::Bars + if state.api_stat_grouping == ApiStatGrouping::Day + && state.api_stat_orientation == ChartOrientation::Vertical => + { + render_api_daily_chart(frame, area, usage, state) + } + ApiStatGraph::Bars => { + let points = aggregate_api_stat_points( + usage.daily_usage_buckets.as_deref().unwrap_or(&[]), + state.api_stat_grouping, + crate::usage::system_first_weekday(), + ); + render_api_grouped_bars(frame, area, &points, state); + } + } +} + +fn aggregate_api_stat_points( + buckets: &[crate::codex_rpc::DailyUsageBucket], + grouping: ApiStatGrouping, + first_weekday: Weekday, +) -> Vec { + let mut grouped = BTreeMap::::new(); + for bucket in buckets { + let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else { + continue; + }; + let start = api_stat_period_start(date, grouping, first_weekday); + let entry = grouped.entry(start).or_default(); + entry.0 = entry.0.saturating_add(bucket.tokens); + entry.1 = entry.1.saturating_add(1); + } + grouped + .into_iter() + .map(|(start, (tokens, active_days))| ApiStatPoint { + start, + end: api_stat_period_end(start, grouping), + tokens, + active_days, + }) + .collect() +} + +fn api_stat_period_start( + date: NaiveDate, + grouping: ApiStatGrouping, + _first_weekday: Weekday, +) -> NaiveDate { + match grouping { + ApiStatGrouping::Day => date, + ApiStatGrouping::Week => { + date - ChronoDuration::days(date.weekday().num_days_from_monday() as i64) + } + ApiStatGrouping::Month => { + NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap_or(date) + } + } +} + +fn api_stat_period_end(start: NaiveDate, grouping: ApiStatGrouping) -> NaiveDate { + match grouping { + ApiStatGrouping::Day => start, + ApiStatGrouping::Week => start + ChronoDuration::days(6), + ApiStatGrouping::Month => { + let (year, month) = if start.month() == 12 { + (start.year().saturating_add(1), 1) + } else { + (start.year(), start.month() + 1) + }; + NaiveDate::from_ymd_opt(year, month, 1) + .map(|next| next - ChronoDuration::days(1)) + .unwrap_or(start) + } + } +} + +fn api_stat_grouping_label(grouping: ApiStatGrouping) -> &'static str { + match grouping { + ApiStatGrouping::Day => "DAY", + ApiStatGrouping::Week => "WEEK", + ApiStatGrouping::Month => "MONTH", + } +} + +fn format_api_stat_point_label( + point: &ApiStatPoint, + grouping: ApiStatGrouping, + formatter: DisplayFormatter<'_>, +) -> String { + match grouping { + ApiStatGrouping::Day => formatter.format_short_date(point.start), + ApiStatGrouping::Week => { + let range = if point.start.month() == point.end.month() { + format!( + "{} {}-{}", + formatter.abbreviated_month(point.start.month()), + point.start.day(), + point.end.day() + ) + } else { + format!( + "{} {}-{} {}", + formatter.abbreviated_month(point.start.month()), + point.start.day(), + formatter.abbreviated_month(point.end.month()), + point.end.day() + ) + }; + format!("W{:02} ({range})", point.start.iso_week().week()) + } + ApiStatGrouping::Month => format!( + "{} {}", + formatter.abbreviated_month(point.start.month()), + point.start.year() + ), + } +} + +fn format_api_stat_point_tooltip( + point: &ApiStatPoint, + grouping: ApiStatGrouping, + formatter: DisplayFormatter<'_>, +) -> String { + let period = if grouping == ApiStatGrouping::Day { + formatter.format_full_date(point.start) + } else { + format!( + "{} - {}", + formatter.format_full_date(point.start), + formatter.format_full_date(point.end) + ) + }; + format!( + "{period} | {} tokens | {} active days", + formatter.format_u64(point.tokens), + formatter.format_usize(point.active_days) + ) +} + +fn render_api_grouped_bars( + frame: &mut Frame<'_>, + area: Rect, + points: &[ApiStatPoint], + state: &mut AppState, +) { + let grouping = state.api_stat_grouping; + let inner = inset_with_border_and_padding( + area, + Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }, + ); + let visible_capacity = match state.api_stat_orientation { + ChartOrientation::Vertical => vertical_bar_capacity(inner.width), + ChartOrientation::Horizontal => { + let bar_height = horizontal_bar_height(points.len(), inner.height); + usize::from((inner.height / bar_height).max(1)) + } + }; + let scrollbar_axis = match state.api_stat_orientation { + ChartOrientation::Vertical => ChartScrollbarAxis::Horizontal, + ChartOrientation::Horizontal => ChartScrollbarAxis::Vertical, + }; + let (chart_inner, scrollbar_area) = + reserve_chart_scrollbar(inner, scrollbar_axis, points.len() > visible_capacity); + let (start, end) = update_api_stat_viewport(state, area, points.len(), visible_capacity); + let visible = &points[start..end]; + let formatter = state.formatter(); + let title = format!(" TOKEN_ACTIVITY_BY_{} ", api_stat_grouping_label(grouping)); + let count = format!( + " {} PERIODS ", + viewport_label(points.len(), start, end, formatter) + ); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .padding(Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }) + .title(Span::styled(title, Style::default().fg(Color::Gray))) + .title_top( + Line::from(Span::styled(count, Style::default().fg(Color::Gray))).right_aligned(), + ); + frame.render_widget(block, area); + if points.is_empty() { + frame.render_widget( + Paragraph::new("The account service returned no daily usage buckets.") + .style(Style::default().fg(Color::Gray)), + inner, + ); + return; + } + + match state.api_stat_orientation { + ChartOrientation::Vertical => { + render_api_grouped_vertical_bars(frame, chart_inner, visible, grouping, state) + } + ChartOrientation::Horizontal => { + render_api_grouped_horizontal_bars(frame, chart_inner, visible, grouping, state) + } + } + if let Some(scrollbar_area) = scrollbar_area { + render_chart_scrollbar( + frame, + scrollbar_area, + scrollbar_axis, + ChartScrollbarViewport { + total: points.len(), + visible: end.saturating_sub(start), + offset_from_newest: state.api_stat_period_offset, + }, + ChartScrollbarOwner::ApiStat, + state, + ); + } +} + +fn render_api_grouped_vertical_bars( + frame: &mut Frame<'_>, + area: Rect, + points: &[ApiStatPoint], + grouping: ApiStatGrouping, + state: &mut AppState, +) { + if area.width < 3 || area.height < 3 { + return; + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(2), Constraint::Length(1)]) + .split(area); + let bars_area = chunks[0]; + let labels_area = chunks[1]; + let values = points.iter().map(|point| point.tokens).collect::>(); + let (bar_width, bar_gap) = compute_bar_layout(bars_area, points.len() as u16); + let bar_width = bar_width.max(1); + let max_value = values.iter().copied().max().unwrap_or(0).max(1); + let hovered = hovered_vertical_bar_index( + state.mouse_position, + bars_area, + bar_width, + bar_gap, + &values, + max_value, + ); + let tooltip = hovered.and_then(|index| { + state.mouse_position.map(|mouse| { + ( + mouse, + format_api_stat_point_tooltip(&points[index], grouping, state.formatter()), + ) + }) + }); + let right = bars_area.x.saturating_add(bars_area.width); + let buf = frame.buffer_mut(); + let mut last_label_end = labels_area.x; + for (index, point) in points.iter().enumerate() { + let x = bars_area + .x + .saturating_add((index as u16).saturating_mul(bar_width.saturating_add(bar_gap))); + if x >= right { + break; + } + let width = bar_width.min(right.saturating_sub(x)); + let color = if index % 2 == 0 { + DARK_BAR_COLOR + } else { + LIGHT_BAR_COLOR + }; + let filled = ((bars_area.height as f64) + * (point.tokens as f64 / max_value as f64).clamp(0.0, 1.0)) + .round() as u16; + let bottom = bars_area + .y + .saturating_add(bars_area.height) + .saturating_sub(1); + let top = bottom.saturating_sub(filled.saturating_sub(1)); + for y in bars_area.y..bars_area.y.saturating_add(bars_area.height) { + for cell_x in x..x.saturating_add(width) { + if let Some(cell) = buf.cell_mut((cell_x, y)) { + cell.set_char(' '); + if filled > 0 && y >= top { + cell.set_style(Style::default().bg(color)); + } + } + } + } + if width >= 4 && filled >= 2 { + let value = format_compact_kmb(point.tokens, width, state.formatter()); + let value = truncate_middle(&value, width as usize); + let value_x = x.saturating_add(width.saturating_sub(value.len() as u16) / 2); + let value_y = bottom.saturating_sub(1); + for (offset, ch) in value.chars().enumerate() { + if let Some(cell) = buf.cell_mut((value_x + offset as u16, value_y)) { + cell.set_char(ch).set_style( + Style::default() + .fg(Color::Black) + .bg(color) + .add_modifier(Modifier::BOLD), + ); + } + } + } + + let label = format_api_stat_point_label(point, grouping, state.formatter()); + if x >= last_label_end { + let label = truncate_middle(&label, right.saturating_sub(x) as usize); + for (offset, ch) in label.chars().enumerate() { + if let Some(cell) = buf.cell_mut((x + offset as u16, labels_area.y)) { + cell.set_char(ch) + .set_style(Style::default().fg(Color::Gray)); + } + } + last_label_end = x.saturating_add(label.len() as u16).saturating_add(1); + } + } + if let Some((mouse, text)) = tooltip { + render_chart_tooltip(frame, area, mouse, &text); + } +} + +fn render_api_grouped_horizontal_bars( + frame: &mut Frame<'_>, + area: Rect, + points: &[ApiStatPoint], + grouping: ApiStatGrouping, + state: &mut AppState, +) { + if area.width < 18 || area.height == 0 { + return; + } + let row_height = horizontal_bar_height(points.len(), area.height); + let max_value = points + .iter() + .map(|point| point.tokens) + .max() + .unwrap_or(1) + .max(1); + let desired_label_width = points + .iter() + .map(|point| { + UnicodeWidthStr::width( + format_api_stat_point_label(point, grouping, state.formatter()).as_str(), + ) as u16 + }) + .max() + .unwrap_or(5) + .saturating_add(1); + let label_width = desired_label_width + .min(area.width.saturating_sub(14).max(6)) + .max(6); + let value_width = 17_u16.min(area.width / 3).max(6); + let bar_width = area + .width + .saturating_sub(label_width) + .saturating_sub(value_width) + .saturating_sub(1) + .max(1); + let mut hovered = None; + let buf = frame.buffer_mut(); + for (index, point) in points.iter().enumerate() { + let row_y = area + .y + .saturating_add((index as u16).saturating_mul(row_height)); + if row_y >= area.y.saturating_add(area.height) { + break; + } + let mid_y = row_y.saturating_add(row_height / 2); + let label = truncate_middle( + &format_api_stat_point_label(point, grouping, state.formatter()), + label_width.saturating_sub(1) as usize, + ); + write_text( + buf, + area.x, + mid_y, + label_width.saturating_sub(1), + &label, + Style::default().fg(Color::Gray), + ); + let bar_x = area.x.saturating_add(label_width); + let filled = ((bar_width as f64) * (point.tokens as f64 / max_value as f64).clamp(0.0, 1.0)) + .round() as u16; + let color = if index % 2 == 0 { + DARK_BAR_COLOR + } else { + LIGHT_BAR_COLOR + }; + for y in row_y..row_y.saturating_add(row_height).min(area.y + area.height) { + for offset in 0..bar_width { + if let Some(cell) = buf.cell_mut((bar_x + offset, y)) { + cell.set_char(' '); + if offset < filled { + cell.set_style(Style::default().bg(color)); + } + } + } + } + let value = format_optional_account_tokens(Some(point.tokens), state.formatter()); + let value = truncate_middle(&value, value_width.saturating_sub(1) as usize); + let value_x = area + .x + .saturating_add(area.width) + .saturating_sub(value.len() as u16); + write_text( + buf, + value_x, + mid_y, + value.len() as u16, + &value, + Style::default().fg(Color::Gray), + ); + + if let Some((mouse_x, mouse_y)) = state.mouse_position { + if mouse_y >= row_y + && mouse_y < row_y.saturating_add(row_height) + && mouse_x >= bar_x + && mouse_x < bar_x.saturating_add(filled) + { + hovered = Some(( + (mouse_x, mouse_y), + format_api_stat_point_tooltip(point, grouping, state.formatter()), + )); + } + } + } + if let Some((mouse, text)) = hovered { + render_chart_tooltip(frame, area, mouse, &text); + } +} + +fn render_api_stat_heatmap( + frame: &mut Frame<'_>, + area: Rect, + usage: &crate::codex_rpc::AccountUsage, + state: &mut AppState, +) { + let mut values = BTreeMap::::new(); + for bucket in usage.daily_usage_buckets.as_deref().unwrap_or(&[]) { + if let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") { + values + .entry(date) + .and_modify(|tokens| *tokens = tokens.saturating_add(bucket.tokens)) + .or_insert(bucket.tokens); + } + } + let inner = inset_with_border_and_padding( + area, + Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }, + ); + let first_weekday = Weekday::Mon; + let first_date = *values.keys().next().unwrap_or(&Utc::now().date_naive()); + let last_date = *values.keys().next_back().unwrap_or(&first_date); + let calendar_start = api_stat_period_start(first_date, ApiStatGrouping::Week, first_weekday); + let calendar_end = api_stat_period_end( + api_stat_period_start(last_date, ApiStatGrouping::Week, first_weekday), + ApiStatGrouping::Week, + ); + let weeks_total = if values.is_empty() { + 0 + } else { + ((calendar_end - calendar_start).num_days() / 7 + 1).max(1) as usize + }; + let grid_width = inner.width.saturating_sub(ACTIVITY_WEEKDAY_LABEL_WIDTH); + let cell_width = activity_day_cell_width(grid_width, weeks_total); + let weeks_capacity = usize::from(grid_width / cell_width.max(1)).max(1); + let (chart_inner, scrollbar_area) = reserve_chart_scrollbar( + inner, + ChartScrollbarAxis::Horizontal, + weeks_total > weeks_capacity, + ); + let (week_start, week_end) = update_api_stat_viewport(state, area, weeks_total, weeks_capacity); + let formatter = state.formatter(); + let title = " DAILY_TOKEN_HEATMAP "; + let count = if weeks_total == 0 { + " 0 ACTIVE DAYS ".to_string() + } else { + format!( + " {} ACTIVE DAYS | {} WEEKS ", + formatter.format_usize(values.len()), + viewport_label(weeks_total, week_start, week_end, formatter) + ) + }; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .padding(Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }) + .title(Span::styled(title, Style::default().fg(Color::Gray))) + .title_top( + Line::from(Span::styled(count, Style::default().fg(Color::Gray))).right_aligned(), + ); + frame.render_widget(block, area); + let inner = chart_inner; + if values.is_empty() { + frame.render_widget( + Paragraph::new("The account service returned no daily usage buckets.") + .style(Style::default().fg(Color::Gray)), + inner, + ); + return; + } + if inner.width <= ACTIVITY_WEEKDAY_LABEL_WIDTH || inner.height < 8 { + render_activity_message(frame, inner, "Heatmap needs more space."); + return; + } + + let weeks_visible = week_end.saturating_sub(week_start); + if weeks_visible == 0 { + return; + } + let visible_start = calendar_start + ChronoDuration::weeks(week_start as i64); + let grid_x = inner.x.saturating_add(ACTIVITY_WEEKDAY_LABEL_WIDTH); + let max_value = values.values().copied().max().unwrap_or(0); + let buf = frame.buffer_mut(); + + let mut next_free_x = grid_x; + for week in 0..weeks_visible { + let week_start = visible_start + ChronoDuration::weeks(week as i64); + let label_date = if week == 0 { + Some(week_start) + } else { + (0..7) + .map(|day| week_start + ChronoDuration::days(day)) + .find(|date| date.day() == 1) + }; + let label = label_date.map(|date| formatter.abbreviated_month(date.month())); + if let Some(label) = label { + let x = grid_x.saturating_add((week as u16).saturating_mul(cell_width)); + if x >= next_free_x { + write_text( + buf, + x, + inner.y, + UnicodeWidthStr::width(label.as_str()) as u16, + &label, + Style::default().fg(Color::Gray), + ); + next_free_x = x + .saturating_add(UnicodeWidthStr::width(label.as_str()) as u16) + .saturating_add(1); + } + } + } + + for row in 0..7usize { + let y = inner.y.saturating_add(1 + row as u16); + write_text( + buf, + inner.x, + y, + ACTIVITY_WEEKDAY_LABEL_WIDTH, + &activity_weekday_label(first_weekday, row, formatter), + Style::default().fg(Color::Gray), + ); + for week in 0..weeks_visible { + let date = visible_start + + ChronoDuration::weeks(week as i64) + + ChronoDuration::days(row as i64); + let tokens = values.get(&date).copied().unwrap_or(0); + let level = api_stat_color_level(tokens, max_value); + let x = grid_x.saturating_add((week as u16).saturating_mul(cell_width)); + write_activity_cell(buf, x, y, cell_width, level); + } + } + + let tooltip = state.mouse_position.and_then(|mouse| { + if mouse.0 < grid_x + || mouse.0 >= grid_x.saturating_add((weeks_visible as u16) * cell_width) + || mouse.1 < inner.y.saturating_add(1) + || mouse.1 >= inner.y.saturating_add(8) + { + return None; + } + let week = usize::from((mouse.0 - grid_x) / cell_width); + let row = i64::from(mouse.1 - inner.y - 1); + let date = visible_start + ChronoDuration::weeks(week as i64) + ChronoDuration::days(row); + let tokens = values.get(&date).copied().unwrap_or(0); + Some(( + mouse, + format!( + "{} | {} tokens", + formatter.format_full_date(date), + formatter.format_u64(tokens) + ), + )) + }); + if let Some((mouse, text)) = tooltip { + render_chart_tooltip(frame, inner, mouse, &text); + } + if let Some(scrollbar_area) = scrollbar_area { + render_chart_scrollbar( + frame, + scrollbar_area, + ChartScrollbarAxis::Horizontal, + ChartScrollbarViewport { + total: weeks_total, + visible: week_end.saturating_sub(week_start), + offset_from_newest: state.api_stat_period_offset, + }, + ChartScrollbarOwner::ApiStat, + state, + ); + } +} + +fn api_stat_color_level(value: u64, max_value: u64) -> usize { + if value == 0 || max_value == 0 { + return 0; + } + let level = ((value as f64 / max_value as f64) * ACTIVITY_COLORS.len() as f64).ceil() as usize; + level.clamp(1, ACTIVITY_COLORS.len()) +} + +fn render_api_daily_chart( + frame: &mut Frame<'_>, + area: Rect, + usage: &crate::codex_rpc::AccountUsage, + state: &mut AppState, +) { + let buckets = usage.daily_usage_buckets.as_deref().unwrap_or(&[]); + let inner = inset_with_border_and_padding( + area, + Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }, + ); + let visible_capacity = vertical_bar_capacity(inner.width); + let (chart_inner, scrollbar_area) = reserve_chart_scrollbar( + inner, + ChartScrollbarAxis::Horizontal, + buckets.len() > visible_capacity, + ); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(2), Constraint::Length(1)]) + .split(chart_inner); + let bars_area = chunks[0]; + let labels_area = chunks[1]; + let (visible_start, visible_end) = + update_api_stat_viewport(state, area, buckets.len(), visible_capacity); + let visible = &buckets[visible_start..visible_end]; + let formatter = state.formatter(); + let count_label = if buckets.is_empty() { + " NO DAILY DATA ".to_string() + } else { + let range = visible + .first() + .zip(visible.last()) + .map(|(first, last)| { + let first = NaiveDate::parse_from_str(&first.start_date, "%Y-%m-%d") + .map(|date| formatter.format_full_date(date)) + .unwrap_or_else(|_| first.start_date.clone()); + let last = NaiveDate::parse_from_str(&last.start_date, "%Y-%m-%d") + .map(|date| formatter.format_full_date(date)) + .unwrap_or_else(|_| last.start_date.clone()); + format!(" {first} - {last} ") + }) + .unwrap_or_default(); + format!( + " {} ACTIVE DAYS{range}", + viewport_label(buckets.len(), visible_start, visible_end, formatter) + ) + }; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .padding(Padding { + left: 1, + right: 1, + top: 1, + bottom: 0, + }) + .title(Span::styled( + " DAILY_TOKEN_ACTIVITY ", + Style::default().fg(Color::Gray), + )) + .title_top( + Line::from(Span::styled(count_label, Style::default().fg(Color::Gray))).right_aligned(), + ); + frame.render_widget(block.clone(), area); + + if buckets.is_empty() || chart_inner.width < 3 || chart_inner.height < 3 { + if buckets.is_empty() { + frame.render_widget( + Paragraph::new("The account service returned no daily usage buckets.") + .style(Style::default().fg(Color::Gray)), + chart_inner, + ); + } + return; + } + + let values = visible + .iter() + .map(|bucket| bucket.tokens) + .collect::>(); + let (bar_width, bar_gap) = compute_bar_layout(bars_area, visible.len() as u16); + let bar_width = bar_width.max(1); + let max_value = values.iter().copied().max().unwrap_or(0).max(1); + let hovered = hovered_vertical_bar_index( + state.mouse_position, + bars_area, + bar_width, + bar_gap, + &values, + max_value, + ); + let tooltip = hovered.and_then(|index| { + state.mouse_position.map(|mouse| { + ( + mouse, + format_vertical_bar_tooltip( + &visible[index].start_date, + visible[index].tokens, + UsageMetric::Tokens, + formatter, + ), + ) + }) + }); + + let buf = frame.buffer_mut(); + let mut previous_month = None; + let mut last_label_end = labels_area.x; + for (index, bucket) in visible.iter().enumerate() { + let x = bars_area + .x + .saturating_add((index as u16).saturating_mul(bar_width.saturating_add(bar_gap))); + let right = bars_area.x.saturating_add(bars_area.width); + if x >= right { + break; + } + let width = bar_width.min(right.saturating_sub(x)); + let fill_bg = if index % 2 == 0 { + DARK_BAR_COLOR + } else { + LIGHT_BAR_COLOR + }; + let filled_height = ((bars_area.height as f64) + * (bucket.tokens as f64 / max_value as f64).clamp(0.0, 1.0)) + .round() as u16; + let bottom = bars_area + .y + .saturating_add(bars_area.height) + .saturating_sub(1); + let top = bottom.saturating_sub(filled_height.saturating_sub(1)); + for y in bars_area.y..bars_area.y.saturating_add(bars_area.height) { + for cell_x in x..x.saturating_add(width) { + if let Some(cell) = buf.cell_mut((cell_x, y)) { + cell.set_char(' '); + if filled_height > 0 && y >= top { + cell.set_style(Style::default().bg(fill_bg)); + } + } + } + } + + if width >= 4 && filled_height >= 2 { + let value = format_compact_kmb(bucket.tokens, width, formatter); + let value = truncate_middle(&value, width as usize); + let value_x = x.saturating_add(width.saturating_sub(value.len() as u16) / 2); + let value_y = bottom.saturating_sub(1); + for (offset, ch) in value.chars().enumerate() { + if let Some(cell) = buf.cell_mut((value_x + offset as u16, value_y)) { + cell.set_char(ch).set_style( + Style::default() + .fg(Color::Black) + .bg(fill_bg) + .add_modifier(Modifier::BOLD), + ); + } + } + } + + let parsed = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d").ok(); + let month_key = parsed.map(|date| (date.year(), date.month())); + let label = if width >= 5 { + parsed.map(|date| formatter.format_short_date(date)) + } else if month_key != previous_month { + parsed.map(|date| formatter.abbreviated_month(date.month())) + } else { + None + }; + previous_month = month_key; + if let Some(label) = label { + if x >= last_label_end { + let label = truncate_middle(&label, right.saturating_sub(x) as usize); + for (offset, ch) in label.chars().enumerate() { + if let Some(cell) = buf.cell_mut((x + offset as u16, labels_area.y)) { + cell.set_char(ch) + .set_style(Style::default().fg(Color::Gray)); + } + } + last_label_end = x.saturating_add(label.len() as u16).saturating_add(1); + } + } + } + + if let Some((mouse, text)) = tooltip { + render_chart_tooltip(frame, chart_inner, mouse, &text); + } + if let Some(scrollbar_area) = scrollbar_area { + render_chart_scrollbar( + frame, + scrollbar_area, + ChartScrollbarAxis::Horizontal, + ChartScrollbarViewport { + total: buckets.len(), + visible: visible_end.saturating_sub(visible_start), + offset_from_newest: state.api_stat_period_offset, + }, + ChartScrollbarOwner::ApiStat, + state, + ); + } +} + +fn footer_hint(screen: ActiveScreen) -> &'static str { + match screen { + ActiveScreen::Usage => { + "Usage: Statistic [tab] (tokens/time/runs), Group [g/w] (day/week/month), Layout [f] (horizontal/vertical), Zone [z/F6] (local/UTC), Scroll [wheel/arrows/PgUp/PgDn/Home/End], Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" + } + ActiveScreen::Activity => { + "Activity: Statistic [tab] (tokens/time/runs), Projects [+/-], Scroll [wheel/left/right/PgUp/PgDn/Home/End], Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" + } + ActiveScreen::ApiStat => { + "Codex API stats: View [b] (bars/heat), Group [g] (day/week/month), Layout [f] (vertical/horizontal), Zone: UTC (server), Scroll [wheel/arrows/PgUp/PgDn/Home/End], Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" + } + ActiveScreen::LimitResets => { + "Limit resets: Refresh [r/F5], Switch [s/F2], Help [?], Quit [q]" + } + ActiveScreen::Read => "", + } +} + +fn footer_error(state: &AppState) -> String { + let err = match state.active_screen { + ActiveScreen::Usage => state + .usage_error + .as_deref() + .or(state.limits_error.as_deref()), + ActiveScreen::Activity => state.usage_error.as_deref(), + ActiveScreen::ApiStat => state.account_usage_error.as_deref(), + ActiveScreen::LimitResets => state.limits_error.as_deref(), + ActiveScreen::Read => None, + } + .unwrap_or(""); + if err.is_empty() { + String::new() + } else { + truncate_middle(err, 80) + } +} + +fn footer_text(state: &AppState) -> String { + let hint = footer_hint(state.active_screen); + let style = format!("Style [n]: {}", state.formatter().style_label()); + let err = footer_error(state); + if err.is_empty() { + format!("{hint} {style}") + } else { + format!("{hint} {style} {err}") + } +} + +fn footer_height(width: u16, state: &AppState) -> u16 { + wrapped_line_count(&footer_text(state), width).max(1) +} + +fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let err = footer_error(state); + let style = format!("Style [n]: {}", state.formatter().style_label()); + let line = Line::from(vec![ + Span::styled( + footer_hint(state.active_screen), + Style::default().fg(Color::Gray), + ), + Span::raw(" "), + Span::styled(style, Style::default().fg(Color::Gray)), + Span::raw(if err.is_empty() { "" } else { " " }), + Span::styled(err, Style::default().fg(Color::Red)), + ]); + + frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: true }), area); +} + +fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + let cards_height = usage_cards_height(state, area.width); + let reset_summary = reset_summary_text(state); + let controls_height = usage_controls_height(reset_summary.as_deref(), area.width); + let chunks = usage_layout(area, controls_height, cards_height); + + render_usage_controls(frame, chunks[0], state, reset_summary.as_deref()); + render_usage_cards(frame, chunks[1], state); + render_usage_chart(frame, chunks[2], state); + render_top_models(frame, chunks[3], state); +} + +fn usage_layout(area: Rect, controls_height: u16, cards_height: u16) -> [Rect; 4] { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(controls_height), + Constraint::Length(cards_height), + // Cards must keep their measured height. `Min` has higher priority than `Length` + // in ratatui, so a short terminal would otherwise collapse the cards to borders in + // order to preserve the chart's minimum height. Let the chart consume the remaining + // space and degrade first instead. + Constraint::Fill(1), + Constraint::Length(3), + ]) + .split(area); + [chunks[0], chunks[1], chunks[2], chunks[3]] +} + +fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + let cards_height = usage_cards_height(state, area.width); + let reset_summary = reset_summary_text(state); + let controls_height = activity_controls_height(reset_summary.as_deref(), area.width); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(controls_height), + Constraint::Length(cards_height), + Constraint::Min(1), + ]) + .split(area); + + render_activity_controls(frame, chunks[0], state, reset_summary.as_deref()); + render_usage_cards(frame, chunks[1], state); + render_activity_heatmaps(frame, chunks[2], state); +} + +fn render_activity_controls( + frame: &mut Frame<'_>, + area: Rect, + state: &mut AppState, + reset_summary: Option<&str>, +) { + let reset_height = reset_summary_height(reset_summary, area.width); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(reset_height)]) + .split(area); + + let workspace_label = state + .workspace_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "All workspaces".to_string()); + + let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); + let time = pill("TIME", state.metric == UsageMetric::Time); + let runs = pill("RUNS", state.metric == UsageMetric::Runs); + let project_count = state.activity_project_limit.to_string(); + + render_flat_activity_controls( + frame, + chunks[0], + state, + &workspace_label, + vec![tokens, time, runs], + project_count, + ); + + if let Some(text) = reset_summary { + render_reset_summary(frame, chunks[1], text); + } +} + +fn render_flat_activity_controls( + frame: &mut Frame<'_>, + area: Rect, + state: &mut AppState, + workspace_label: &str, + metric_pills: Vec>, + project_count: String, +) { + let row = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Min(0)]) + .split(area); + render_workspace_label(frame, row[0], workspace_label); + + register_right_aligned_targets( + state, + row[1], + &[ + (" VIEW ", None), + ( + " TOKENS ", + Some(UiClickAction::SetMetric(UsageMetric::Tokens)), + ), + (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), + (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), + (" PROJECTS ", None), + (" + ", Some(UiClickAction::IncreaseProjects)), + (&project_count, None), + (" -", Some(UiClickAction::DecreaseProjects)), + ], + ); + + let mut spans = vec![control_group_label("VIEW")]; + spans.extend(metric_pills); + spans.push(control_group_label("PROJECTS")); + spans.extend(activity_project_control_spans(project_count)); + frame.render_widget( + Paragraph::new(Line::from(spans)).alignment(Alignment::Right), + row[1], + ); +} + +fn activity_project_control_spans(project_count: String) -> [Span<'static>; 3] { + [ + pill("+", false), + Span::styled(project_count, Style::default().add_modifier(Modifier::BOLD)), + Span::styled(" -", Style::default().fg(Color::Gray)), + ] } -fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &AppState) { +fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let metric_label = match state.metric { UsageMetric::Tokens => "TOKENS", UsageMetric::Time => "TIME", UsageMetric::Runs => "RUNS", }; + let inner = inset_with_border_and_padding( + area, + Padding { + left: 2, + right: 2, + top: 1, + bottom: 0, + }, + ); + if let Some((indexed, total)) = state + .usage + .as_ref() + .filter(|snapshot| snapshot.scan_pending_files > 0) + .map(|snapshot| (snapshot.scan_indexed_files, snapshot.scan_total_files)) + { + state.activity_scroll_area = Some(area); + state.activity_total_weeks = 0; + state.activity_visible_weeks = 0; + state.activity_week_offset = 0; + let formatter = state.formatter(); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title_top( + Line::from(Span::styled( + format!( + " INDEXING {} / {} ", + formatter.format_usize(indexed), + formatter.format_usize(total) + ), + Style::default().fg(Color::Gray), + )) + .left_aligned(), + ) + .title_top( + Line::from(Span::styled( + format!(" {metric_label} "), + Style::default().add_modifier(Modifier::BOLD), + )) + .right_aligned(), + ); + frame.render_widget(block, area); + render_activity_message(frame, inner, "Indexing local sessions. Please wait."); + return; + } + let grid_width = inner.width.saturating_sub(ACTIVITY_WEEKDAY_LABEL_WIDTH); + let cell_width = activity_day_cell_width(grid_width, ACTIVITY_TIMELINE_WEEKS); + let weeks_visible = ACTIVITY_TIMELINE_WEEKS.min((grid_width / cell_width.max(1)) as usize); + let (chart_inner, scrollbar_area) = reserve_chart_scrollbar( + inner, + ChartScrollbarAxis::Horizontal, + weeks_visible < ACTIVITY_TIMELINE_WEEKS, + ); + state.activity_scroll_area = Some(area); + state.activity_total_weeks = ACTIVITY_TIMELINE_WEEKS; + state.activity_visible_weeks = weeks_visible; + let max_offset = ACTIVITY_TIMELINE_WEEKS.saturating_sub(weeks_visible); + state.activity_week_offset = state.activity_week_offset.min(max_offset); + let older = if state.activity_week_offset < max_offset { + "<" + } else { + " " + }; + let newer = if state.activity_week_offset > 0 { + ">" + } else { + " " + }; + let range_title = if max_offset == 0 { + format!(" Last {ACTIVITY_TIMELINE_WEEKS} weeks ") + } else { + format!( + " {older} Weeks {}-{} of {ACTIVITY_TIMELINE_WEEKS} {newer} ", + max_offset + .saturating_sub(state.activity_week_offset) + .saturating_add(1), + max_offset + .saturating_sub(state.activity_week_offset) + .saturating_add(weeks_visible) + ) + }; let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Plain) - .title_top( - Line::from(Span::styled( - format!(" Last {ACTIVITY_TIMELINE_WEEKS} weeks "), - Style::default().fg(Color::Gray), - )) - .left_aligned(), + .title_top( + Line::from(Span::styled(range_title, Style::default().fg(Color::Gray))).left_aligned(), ) .title_top( Line::from(Span::styled( @@ -551,16 +2123,21 @@ fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &AppState) .right_aligned(), ); frame.render_widget(block, area); - - let inner = inset_with_border_and_padding( - area, - Padding { - left: 2, - right: 2, - top: 1, - bottom: 0, - }, - ); + if let Some(scrollbar_area) = scrollbar_area { + render_chart_scrollbar( + frame, + scrollbar_area, + ChartScrollbarAxis::Horizontal, + ChartScrollbarViewport { + total: ACTIVITY_TIMELINE_WEEKS, + visible: weeks_visible, + offset_from_newest: state.activity_week_offset, + }, + ChartScrollbarOwner::Activity, + state, + ); + } + let inner = chart_inner; let Some(snapshot) = state.usage.as_ref() else { render_activity_message(frame, inner, "Loading activity..."); @@ -597,6 +2174,7 @@ fn render_activity_heatmaps(frame: &mut Frame<'_>, area: Rect, state: &AppState) state.metric, snapshot.activity_first_weekday, state.formatter(), + state.activity_week_offset, ); y = y.saturating_add(ACTIVITY_PROJECT_STRIDE); if y >= inner.y.saturating_add(inner.height) { @@ -623,6 +2201,7 @@ fn render_project_activity_heatmap( metric: UsageMetric, first_weekday: Weekday, formatter: DisplayFormatter<'_>, + week_offset_from_newest: usize, ) { if area.height < ACTIVITY_PROJECT_HEIGHT || area.width <= ACTIVITY_WEEKDAY_LABEL_WIDTH { return; @@ -638,7 +2217,8 @@ fn render_project_activity_heatmap( if weeks_visible == 0 { return; } - let week_offset = weeks_total.saturating_sub(weeks_visible); + let max_offset = weeks_total.saturating_sub(weeks_visible); + let week_offset = max_offset.saturating_sub(week_offset_from_newest.min(max_offset)); let grid_x = area.x.saturating_add(ACTIVITY_WEEKDAY_LABEL_WIDTH); let header = format_activity_project_header(project, metric, area.width as usize, formatter); @@ -946,10 +2526,13 @@ fn render_usage_controls( let tokens = pill("TOKENS", state.metric == UsageMetric::Tokens); let time = pill("TIME", state.metric == UsageMetric::Time); let runs = pill("RUNS", state.metric == UsageMetric::Runs); + let day = pill("DAY", state.range == ChartRange::Day); let week = pill("WEEK", state.range == ChartRange::Week); let month = pill("MONTH", state.range == ChartRange::Month); let vert = pill("VERT", state.orientation == ChartOrientation::Vertical); let horz = pill("HORZ", state.orientation == ChartOrientation::Horizontal); + let local = pill("LOCAL", state.usage_zone == UsageZone::Local); + let utc = pill("UTC", state.usage_zone == UsageZone::Utc); let classic = pill("CLASS", state.display_style == DisplayStyle::Classic); let system_compact = pill("SCOMP", state.display_style == DisplayStyle::SystemCompact); let system_full = pill("SFULL", state.display_style == DisplayStyle::SystemFull); @@ -963,10 +2546,13 @@ fn render_usage_controls( tokens, time, runs, + day, week, month, vert, horz, + local, + utc, classic, system_compact, system_full, @@ -999,7 +2585,7 @@ fn render_flat_usage_controls( ) { let row = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(20), Constraint::Length(92)]) + .constraints([Constraint::Min(20), Constraint::Length(116)]) .split(area); render_workspace_label(frame, row[0], workspace_label); @@ -1015,6 +2601,7 @@ fn render_flat_usage_controls( (" TIME ", Some(UiClickAction::SetMetric(UsageMetric::Time))), (" RUNS ", Some(UiClickAction::SetMetric(UsageMetric::Runs))), (" GRAPH ", None), + (" DAY ", Some(UiClickAction::SetRange(ChartRange::Day))), (" WEEK ", Some(UiClickAction::SetRange(ChartRange::Week))), (" MONTH ", Some(UiClickAction::SetRange(ChartRange::Month))), (" BARS ", None), @@ -1026,6 +2613,12 @@ fn render_flat_usage_controls( " HORZ ", Some(UiClickAction::SetOrientation(ChartOrientation::Horizontal)), ), + (" ZONE ", None), + ( + " LOCAL ", + Some(UiClickAction::SetUsageZone(UsageZone::Local)), + ), + (" UTC ", Some(UiClickAction::SetUsageZone(UsageZone::Utc))), (" STYLE ", None), ( " CLASS ", @@ -1045,11 +2638,13 @@ fn render_flat_usage_controls( let mut spans = vec![control_group_label("VIEW")]; spans.extend(pills[0..3].iter().cloned()); spans.push(control_group_label("GRAPH")); - spans.extend(pills[3..5].iter().cloned()); + spans.extend(pills[3..6].iter().cloned()); spans.push(control_group_label("BARS")); - spans.extend(pills[5..7].iter().cloned()); + spans.extend(pills[6..8].iter().cloned()); + spans.push(control_group_label("ZONE")); + spans.extend(pills[8..10].iter().cloned()); spans.push(control_group_label("STYLE")); - spans.extend(pills[7..10].iter().cloned()); + spans.extend(pills[10..13].iter().cloned()); frame.render_widget( Paragraph::new(Line::from(spans)).alignment(Alignment::Right), row[1], @@ -1151,11 +2746,29 @@ fn limits_card_content(state: &AppState, compact: bool) -> (String, Vec) fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { let formatter = state.formatter(); let snapshot = state.usage.as_ref(); - let totals = snapshot.map(|snapshot| snapshot.totals_view(state.metric, formatter)); - let today = snapshot.and_then(|snapshot| snapshot.days.last()); + let totals = snapshot + .map(|snapshot| snapshot.totals_view_for_zone(state.metric, formatter, state.usage_zone)); + let today = snapshot.and_then(|snapshot| snapshot.days_for_zone(state.usage_zone).last()); let (limits_value, limits_captions) = limits_card_content(state, uses_compact_limit_lines(card_width)); + if let Some(pending) = snapshot.filter(|snapshot| snapshot.scan_pending_files > 0) { + let progress = format!( + "{} / {} files", + formatter.format_usize(pending.scan_indexed_files), + formatter.format_usize(pending.scan_total_files) + ); + let mut cards = Vec::with_capacity(6); + cards.push(CardSpec::new(limits_value, limits_captions)); + for _ in 0..5 { + cards.push(CardSpec::new( + "INDEXING".to_string(), + vec![progress.clone(), "Please wait".to_string()], + )); + } + return cards; + } + let today_value = today .map(|day| { format!( @@ -1176,14 +2789,20 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { let last7_runs = snapshot .map(|snapshot| { snapshot - .last7_days() + .last7_days_for_zone(state.usage_zone) .iter() .map(|day| day.agent_runs) .sum::() }) .map(|runs| format!("Runs {}", format_count(runs, formatter))); let last30_runs = snapshot - .map(|snapshot| snapshot.days.iter().map(|day| day.agent_runs).sum::()) + .map(|snapshot| { + snapshot + .days_for_zone(state.usage_zone) + .iter() + .map(|day| day.agent_runs) + .sum::() + }) .map(|runs| format!("Runs {}", format_count(runs, formatter))); let mut cards = Vec::with_capacity(6); @@ -1301,11 +2920,12 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .map(|totals| totals.peak_sub_label.clone()) .unwrap_or_default(); let avg30 = snapshot - .filter(|snapshot| !snapshot.days.is_empty()) + .filter(|snapshot| !snapshot.days_for_zone(state.usage_zone).is_empty()) .map(|snapshot| { - let total_runs = snapshot.days.iter().map(|day| day.agent_runs).sum::(); + let days = snapshot.days_for_zone(state.usage_zone); + let total_runs = days.iter().map(|day| day.agent_runs).sum::(); format_count( - (total_runs as f64 / snapshot.days.len() as f64).round() as i64, + (total_runs as f64 / days.len() as f64).round() as i64, formatter, ) }) @@ -1314,7 +2934,10 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .map(|snapshot| { format!( "Tokens {}", - format_tokens_compact(snapshot.totals.last7_days_tokens, formatter) + format_tokens_compact( + snapshot.totals_for_zone(state.usage_zone).last7_days_tokens, + formatter, + ) ) }) .unwrap_or_else(|| "--".to_string()); @@ -1322,14 +2945,19 @@ fn usage_card_specs(state: &AppState, card_width: u16) -> Vec { .map(|snapshot| { format!( "Tokens {}", - format_tokens_compact(snapshot.totals.last30_days_tokens, formatter) + format_tokens_compact( + snapshot + .totals_for_zone(state.usage_zone) + .last30_days_tokens, + formatter, + ) ) }) .unwrap_or_else(|| "--".to_string()); let time7 = snapshot .map(|snapshot| { let total_ms = snapshot - .last7_days() + .last7_days_for_zone(state.usage_zone) .iter() .map(|day| day.agent_time_ms) .sum::(); @@ -1382,7 +3010,11 @@ fn usage_cards_height(state: &AppState, width: u16) -> u16 { fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let formatter = state.formatter(); - let today_title = today_card_title(formatter, Local::now().naive_local()); + let today_now = match state.usage_zone { + UsageZone::Local => Local::now().naive_local(), + UsageZone::Utc => Utc::now().naive_utc(), + }; + let today_title = today_card_title(formatter, today_now); let card_layout = usage_card_layout(area.width); let row_heights = usage_card_row_heights(state, area.width); let two_rows = row_heights.len() > 1; @@ -1400,8 +3032,9 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { }; let snapshot = state.usage.as_ref(); - let totals = snapshot.map(|s| s.totals_view(state.metric, formatter)); - let today = snapshot.and_then(|s| s.days.last()); + let totals = + snapshot.map(|s| s.totals_view_for_zone(state.metric, formatter, state.usage_zone)); + let today = snapshot.and_then(|s| s.days_for_zone(state.usage_zone).last()); // LIMITS card (live from Codex app-server). let (limits_value, limits_caption1, limits_caption2, limits_caption3): ( @@ -1426,6 +3059,98 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ("Loading...".to_string(), None, None, None) }; + if let Some(pending) = state + .usage + .as_ref() + .filter(|snapshot| snapshot.scan_pending_files > 0) + { + let progress = format!( + "{} / {} files", + formatter.format_usize(pending.scan_indexed_files), + formatter.format_usize(pending.scan_total_files) + ); + let aux_title = match state.metric { + UsageMetric::Tokens => "CACHE_HIT_RATE", + UsageMetric::Time => "RUNS", + UsageMetric::Runs => "TIME", + }; + let render_pending = |frame: &mut Frame<'_>, title: &str, target: Rect| { + frame.render_widget( + card(title, "INDEXING", Some(&progress), Some("Please wait")), + target, + ); + }; + if let Some(row1) = row1 { + if row2.is_none() { + let cards = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(17), + Constraint::Percentage(17), + Constraint::Percentage(17), + Constraint::Percentage(17), + Constraint::Percentage(16), + Constraint::Percentage(16), + ]) + .split(row1); + frame.render_widget( + card4( + "LIMITS", + &limits_value, + limits_caption1.as_deref(), + limits_caption2.as_deref(), + limits_caption3.as_deref(), + ), + cards[0], + ); + for (title, target) in [ + (today_title.as_str(), cards[1]), + ("LAST_7_DAYS", cards[2]), + ("LAST_30_DAYS", cards[3]), + (aux_title, cards[4]), + ("PEAK_DAY", cards[5]), + ] { + render_pending(frame, title, target); + } + } else { + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(row1); + frame.render_widget( + card4( + "LIMITS", + &limits_value, + limits_caption1.as_deref(), + limits_caption2.as_deref(), + limits_caption3.as_deref(), + ), + top[0], + ); + render_pending(frame, &today_title, top[1]); + render_pending(frame, "LAST_7_DAYS", top[2]); + } + } + if let Some(row2) = row2 { + let bottom = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(row2); + render_pending(frame, "LAST_30_DAYS", bottom[0]); + render_pending(frame, aux_title, bottom[1]); + render_pending(frame, "PEAK_DAY", bottom[2]); + } + return; + } + // TODAY card always shows Tokens / Runs / Time. let today_value = today .map(|d| { @@ -1443,10 +3168,20 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .unwrap_or_default(); let last7_runs_sum = snapshot - .map(|s| s.last7_days().iter().map(|d| d.agent_runs).sum::()) + .map(|s| { + s.last7_days_for_zone(state.usage_zone) + .iter() + .map(|d| d.agent_runs) + .sum::() + }) .unwrap_or(0); let last30_runs_sum = snapshot - .map(|s| s.days.iter().map(|d| d.agent_runs).sum::()) + .map(|s| { + s.days_for_zone(state.usage_zone) + .iter() + .map(|d| d.agent_runs) + .sum::() + }) .unwrap_or(0); let last7_runs_caption = if snapshot.is_some() { Some(format!("Runs {}", format_count(last7_runs_sum, formatter))) @@ -1785,11 +3520,12 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let avg30 = snapshot .map(|s| { - if s.days.is_empty() { + let days = s.days_for_zone(state.usage_zone); + if days.is_empty() { 0 } else { - let total_runs = s.days.iter().map(|d| d.agent_runs).sum::(); - (total_runs as f64 / s.days.len() as f64).round() as i64 + let total_runs = days.iter().map(|d| d.agent_runs).sum::(); + (total_runs as f64 / days.len() as f64).round() as i64 } }) .unwrap_or(0); @@ -1803,7 +3539,10 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .map(|s| { format!( "Tokens {}", - format_tokens_compact(s.totals.last7_days_tokens, formatter) + format_tokens_compact( + s.totals_for_zone(state.usage_zone).last7_days_tokens, + formatter, + ) ) }) .unwrap_or_else(|| "--".into()); @@ -1811,13 +3550,20 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { .map(|s| { format!( "Tokens {}", - format_tokens_compact(s.totals.last30_days_tokens, formatter) + format_tokens_compact( + s.totals_for_zone(state.usage_zone).last30_days_tokens, + formatter, + ) ) }) .unwrap_or_else(|| "--".into()); let time7 = snapshot .map(|s| { - let ms = s.last7_days().iter().map(|d| d.agent_time_ms).sum::(); + let ms = s + .last7_days_for_zone(state.usage_zone) + .iter() + .map(|d| d.agent_time_ms) + .sum::(); format_duration_compact(ms) }) .unwrap_or_else(|| "--".into()); @@ -1938,21 +3684,217 @@ fn render_usage_cards(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } } -fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { - // Note: We draw bars manually to control label placement and padding. - let formatter = state.formatter(); +fn aggregate_usage_days(days: &[UsageDay], grouping: ChartRange) -> Vec { + let mut grouped = BTreeMap::::new(); + for day in days { + let Ok(date) = NaiveDate::parse_from_str(&day.day, "%Y-%m-%d") else { + continue; + }; + let start = usage_period_start(date, grouping); + let entry = grouped.entry(start).or_insert_with(|| UsageDay { + day: start.format("%Y-%m-%d").to_string(), + input_tokens: 0, + cached_input_tokens: 0, + total_tokens: 0, + agent_time_ms: 0, + agent_runs: 0, + }); + entry.input_tokens = entry.input_tokens.saturating_add(day.input_tokens); + entry.cached_input_tokens = entry + .cached_input_tokens + .saturating_add(day.cached_input_tokens); + entry.total_tokens = entry.total_tokens.saturating_add(day.total_tokens); + entry.agent_time_ms = entry.agent_time_ms.saturating_add(day.agent_time_ms); + entry.agent_runs = entry.agent_runs.saturating_add(day.agent_runs); + } + grouped.into_values().collect() +} + +fn usage_period_start(date: NaiveDate, grouping: ChartRange) -> NaiveDate { + match grouping { + ChartRange::Day => date, + ChartRange::Week => { + date - ChronoDuration::days(date.weekday().num_days_from_monday() as i64) + } + ChartRange::Month => NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap_or(date), + } +} + +fn usage_period_end(start: NaiveDate, grouping: ChartRange) -> NaiveDate { + match grouping { + ChartRange::Day => start, + ChartRange::Week => start + ChronoDuration::days(6), + ChartRange::Month => { + let (year, month) = if start.month() == 12 { + (start.year().saturating_add(1), 1) + } else { + (start.year(), start.month() + 1) + }; + NaiveDate::from_ymd_opt(year, month, 1) + .map(|next| next - ChronoDuration::days(1)) + .unwrap_or(start) + } + } +} + +fn format_usage_period_label( + start: NaiveDate, + grouping: ChartRange, + formatter: DisplayFormatter<'_>, + compact: bool, +) -> String { + match grouping { + ChartRange::Day if compact => format!("{:02}", start.day()), + ChartRange::Day => { + format_day_label_weekday_mmdd(&start.format("%Y-%m-%d").to_string(), formatter) + } + ChartRange::Week if compact => format!("W{:02}", start.iso_week().week()), + ChartRange::Week => { + let end = usage_period_end(start, grouping); + let range = if start.month() == end.month() { + format!( + "{} {}-{}", + formatter.abbreviated_month(start.month()), + start.day(), + end.day() + ) + } else { + format!( + "{} {}-{} {}", + formatter.abbreviated_month(start.month()), + start.day(), + formatter.abbreviated_month(end.month()), + end.day() + ) + }; + format!("W{:02} ({range})", start.iso_week().week()) + } + ChartRange::Month => format!( + "{} {}", + formatter.abbreviated_month(start.month()), + start.year() + ), + } +} +fn format_usage_period_tooltip( + start: NaiveDate, + grouping: ChartRange, + formatter: DisplayFormatter<'_>, +) -> String { + if grouping == ChartRange::Day { + formatter.format_full_date(start) + } else { + format!( + "{} - {}", + formatter.format_full_date(start), + formatter.format_full_date(usage_period_end(start, grouping)) + ) + } +} + +fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + // Note: We draw bars manually to control label placement and padding. let range_label = match state.range { - ChartRange::Week => "Last 7 days", - ChartRange::Month => "Last 30 days", + ChartRange::Day => "Usage by day", + ChartRange::Week => "Usage by ISO week", + ChartRange::Month => "Usage by month", + }; + // Inner area: account for borders and provide padding (top padding = 1 line). + let inner = inset_with_border_and_padding( + area, + Padding { + left: 2, + right: 2, + top: 1, + bottom: 0, + }, + ); + + if let Some(pending) = state + .usage + .as_ref() + .filter(|snapshot| snapshot.scan_pending_files > 0) + { + state.usage_period_offset = 0; + state.usage_visible_periods = 0; + state.usage_total_periods = 0; + state.usage_scroll_area = Some(area); + let formatter = state.formatter(); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title_top( + Line::from(Span::styled( + format!(" {range_label} "), + Style::default().fg(Color::Gray), + )) + .left_aligned(), + ) + .title_top( + Line::from(Span::styled( + format!( + " INDEXING {} / {} ", + formatter.format_usize(pending.scan_indexed_files), + formatter.format_usize(pending.scan_total_files) + ), + Style::default().add_modifier(Modifier::BOLD), + )) + .right_aligned(), + ); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from(""), + Line::from(Span::styled( + "INDEXING LOCAL SESSIONS", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from("Please wait. Values appear when the local snapshot is complete."), + ])) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + inner, + ); + return; + } + + let all_days = state + .usage + .as_ref() + .map(|snapshot| aggregate_usage_days(snapshot.days_for_zone(state.usage_zone), state.range)) + .unwrap_or_default(); + let visible_capacity = match state.orientation { + ChartOrientation::Vertical => vertical_bar_capacity(inner.width), + ChartOrientation::Horizontal => { + let bar_height = usage_horizontal_bar_height(all_days.len(), inner.height); + usize::from((inner.height / bar_height).max(1)) + } }; + let scrollbar_axis = match state.orientation { + ChartOrientation::Vertical => ChartScrollbarAxis::Horizontal, + ChartOrientation::Horizontal => ChartScrollbarAxis::Vertical, + }; + let (chart_inner, scrollbar_area) = + reserve_chart_scrollbar(inner, scrollbar_axis, all_days.len() > visible_capacity); + let (start, end) = update_usage_viewport(state, area, all_days.len(), visible_capacity); + let days = &all_days[start..end]; + let formatter = state.formatter(); let metric_label = usage_chart_metric_label(state.metric); + let range_title = if all_days.len() == days.len() { + range_label.to_string() + } else { + format!( + "{range_label} :: {}", + viewport_label(all_days.len(), start, end, formatter) + ) + }; let mut block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Plain) .title_top( Line::from(Span::styled( - format!(" {range_label} "), + format!(" {range_title} "), Style::default().fg(Color::Gray), )) .left_aligned(), @@ -1969,50 +3911,35 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } frame.render_widget(block, area); - let snapshot = state.usage.as_ref(); - let days = snapshot - .map(|s| match state.range { - ChartRange::Week => s.last7_days(), - ChartRange::Month => s.last_n_days(30), - }) - .unwrap_or_default(); - - // Inner area: account for borders and provide padding (top padding = 1 line). - let inner = inset_with_border_and_padding( - area, - Padding { - left: 2, - right: 2, - top: 1, - bottom: 0, - }, - ); + let inner = chart_inner; if inner.height < 2 { return; } let mut labels: Vec = Vec::with_capacity(days.len()); + let mut tooltip_labels: Vec = Vec::with_capacity(days.len()); let mut values: Vec = Vec::with_capacity(days.len()); let mut token_out_of_cache_values: Vec = Vec::with_capacity(days.len()); - for day in &days { - let label = match state.orientation { - ChartOrientation::Horizontal => format_day_label_weekday_mmdd(&day.day, formatter), - ChartOrientation::Vertical => match state.range { - ChartRange::Week => day.short_label(formatter), - ChartRange::Month => { - // Prefer compact day-of-month labels for dense charts. - if day.day.len() == 10 { - day.day[8..10].to_string() - } else { - day.short_label(formatter) - } - } - }, - }; + for day in days { + let date = NaiveDate::parse_from_str(&day.day, "%Y-%m-%d").ok(); + let label = date + .map(|date| { + format_usage_period_label( + date, + state.range, + formatter, + state.orientation == ChartOrientation::Vertical, + ) + }) + .unwrap_or_else(|| day.day.clone()); + let tooltip = date + .map(|date| format_usage_period_tooltip(date, state.range, formatter)) + .unwrap_or_else(|| day.day.clone()); labels.push(label); + tooltip_labels.push(tooltip); } - for day in &days { + for day in days { let value = match state.metric { UsageMetric::Tokens => day.total_tokens.max(0) as u64, UsageMetric::Time => (day.agent_time_ms.max(0) as u64) / 60_000, @@ -2036,7 +3963,7 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let bars_area = chunks[0]; let labels_area = chunks[1]; - let (bar_width, bar_gap) = compute_bar_layout(bars_area, values.len() as u16, 0); + let (bar_width, bar_gap) = compute_bar_layout(bars_area, values.len() as u16); let bw = bar_width.max(1); let max_value = values.iter().copied().max().unwrap_or(0).max(1); @@ -2053,7 +3980,7 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { ( mouse, format_vertical_bar_tooltip( - &days[index].day, + &tooltip_labels[index], values[index], state.metric, formatter, @@ -2208,7 +4135,10 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { for l in visible_labels { max_label_len = max_label_len.max(l.len()); } - let label_w = (max_label_len as u16).saturating_add(1).clamp(4, 14); + let label_w = (max_label_len as u16) + .saturating_add(1) + .min(inner.width.saturating_sub(12).max(4)) + .max(4); let value_gap: u16 = 1; // spaces between bar and value let min_bar_w: u16 = 10; @@ -2359,6 +4289,20 @@ fn render_usage_chart(frame: &mut Frame<'_>, area: Rect, state: &AppState) { } } } + if let Some(scrollbar_area) = scrollbar_area { + render_chart_scrollbar( + frame, + scrollbar_area, + scrollbar_axis, + ChartScrollbarViewport { + total: all_days.len(), + visible: end.saturating_sub(start), + offset_from_newest: state.usage_period_offset, + }, + ChartScrollbarOwner::Usage, + state, + ); + } } fn usage_chart_metric_label(metric: UsageMetric) -> &'static str { @@ -2604,15 +4548,28 @@ fn format_minutes_hhmm(total_minutes: u64) -> String { fn render_top_models(frame: &mut Frame<'_>, area: Rect, state: &AppState) { let formatter = state.formatter(); - let snapshot = state.usage.as_ref(); - let models = snapshot.map(|s| s.top_models.clone()).unwrap_or_default(); + let snapshot = state + .usage + .as_ref() + .filter(|snapshot| snapshot.scan_pending_files == 0); + let models = snapshot + .map(|s| s.top_models_for_zone(state.usage_zone).to_vec()) + .unwrap_or_default(); let mut spans: Vec> = vec![Span::styled( "TOP_MODELS ", Style::default().fg(Color::Gray), )]; if models.is_empty() { - spans.push(Span::raw("--")); + if state + .usage + .as_ref() + .is_some_and(|snapshot| snapshot.scan_pending_files > 0) + { + spans.push(Span::raw("INDEXING... PLEASE WAIT")); + } else { + spans.push(Span::raw("--")); + } } else { for (idx, m) in models.iter().enumerate() { if idx > 0 { @@ -2633,7 +4590,7 @@ fn render_top_models(frame: &mut Frame<'_>, area: Rect, state: &AppState) { fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) { let w = area.width.min(60); - let h = area.height.min(14); + let h = area.height.min(17); let popup = centered_rect(w, h, area); frame.render_widget(Clear, popup); @@ -2655,8 +4612,10 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) ActiveScreen::Usage => Text::from(vec![ Line::from("Keys:"), Line::from(" Tab - toggle statistic (Tokens/Time/Runs)"), - Line::from(" w - toggle timeframe (Week/Month)"), + Line::from(" g/w - group by day/ISO week/month"), Line::from(" f - toggle layout (Horz/Vert)"), + Line::from(" z/F6 - toggle calendar zone (Local/UTC)"), + Line::from(" Wheel/arrows/PgUp/PgDn/Home/End - scroll periods"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), Line::from(" Mouse - click tabs/controls/format/quit"), Line::from(" r/F5 - refresh usage + limits"), @@ -2669,6 +4628,8 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" Tab - toggle statistic (Tokens/Time/Runs)"), Line::from(" +/= - show more projects"), Line::from(" - - show fewer projects"), + Line::from(" Left/Right or wheel - scroll weeks"), + Line::from(" PgUp/PgDn/Home/End - jump through weeks"), Line::from(" n - cycle display style (Classic/System Compact/Full)"), Line::from(" Mouse - click tabs/view/projects/quit"), Line::from(" r/F5 - refresh usage + limits"), @@ -2676,6 +4637,20 @@ fn render_help_overlay(frame: &mut Frame<'_>, area: Rect, screen: ActiveScreen) Line::from(" ? - toggle help"), Line::from(" q - quit (confirm)"), ]), + ActiveScreen::ApiStat => Text::from(vec![ + Line::from("Keys:"), + Line::from(" g - group bars by day/week/month"), + Line::from(" b - toggle bars/heatmap"), + Line::from(" f - toggle vertical/horizontal bars"), + Line::from(" Zone - UTC from the server"), + Line::from(" Wheel/arrows/PgUp/PgDn/Home/End - scroll periods"), + Line::from(" r/F5 - refresh Codex account statistics"), + Line::from(" n - cycle display style (Classic/System Compact/Full)"), + Line::from(" Mouse - click tabs; hover a daily bar for its exact value"), + Line::from(" s/F2 - switch screen"), + Line::from(" ? - toggle help"), + Line::from(" q - quit (confirm)"), + ]), ActiveScreen::LimitResets => Text::from(vec![ Line::from("Keys:"), Line::from(" r/F5 - refresh reset credits"), @@ -3685,22 +5660,40 @@ fn apply_margin(area: Rect, margin: Margin) -> Rect { } } -fn compute_bar_layout(area: Rect, bars: u16, padding_x: u16) -> (u16, u16) { +fn preferred_vertical_bar_width(area_width: u16) -> u16 { + match area_width { + 0..=47 => 1, + 48..=119 => 2, + _ => 3, + } +} + +fn vertical_bar_capacity(area_width: u16) -> usize { + let bar_width = preferred_vertical_bar_width(area_width); + let slot_width = bar_width.saturating_add(1); + usize::from( + area_width + .saturating_add(1) + .checked_div(slot_width) + .unwrap_or(1) + .max(1), + ) +} + +fn compute_bar_layout(area: Rect, bars: u16) -> (u16, u16) { if bars == 0 { return (1, 1); } - // Roughly account for the chart border + padding. - let inner_width = area - .width - .saturating_sub(2) - .saturating_sub(padding_x.saturating_mul(2)); // Prefer a gap of 1, but fall back to 0 if bars would be too cramped. let mut gap = 1u16; - let mut width = (inner_width.saturating_sub(gap.saturating_mul(bars.saturating_sub(1)))) / bars; + let mut width = area + .width + .saturating_sub(gap.saturating_mul(bars.saturating_sub(1))) + / bars; if width == 0 { gap = 0; - width = inner_width / bars; + width = area.width / bars; } if width == 0 { width = 1; @@ -3734,6 +5727,15 @@ fn inset_with_border_and_padding(area: Rect, padding: Padding) -> Rect { mod tests { use super::*; + #[test] + fn viewport_bounds_move_from_newest_to_older_periods() { + assert_eq!(viewport_bounds(30, 10, 0), (20, 30)); + assert_eq!(viewport_bounds(30, 10, 1), (19, 29)); + assert_eq!(viewport_bounds(30, 10, 99), (0, 10)); + assert_eq!(viewport_bounds(5, 10, 3), (0, 5)); + assert_eq!(viewport_bounds(0, 10, 0), (0, 0)); + } + #[test] fn right_aligned_control_targets_follow_rendered_segments() { let targets = right_aligned_targets( @@ -3767,18 +5769,39 @@ mod tests { #[test] fn navigation_tabs_are_clickable_on_the_outer_border() { - let (title, targets) = navigation_title(Rect::new(4, 2, 60, 20), ActiveScreen::Activity); + let (title, targets) = navigation_title(Rect::new(4, 2, 80, 20), ActiveScreen::Activity); - assert_eq!(title.width(), 35); - assert_eq!(targets.len(), 4); - assert_eq!(targets[0].area, Rect::new(29, 2, 7, 1)); + assert_eq!(title.width(), 44); + assert_eq!(targets.len(), 5); + assert_eq!(targets[0].area, Rect::new(40, 2, 7, 1)); assert_eq!( targets[0].action, UiClickAction::SetScreen(ActiveScreen::Usage) ); - assert_eq!(targets[1].area, Rect::new(36, 2, 10, 1)); - assert_eq!(targets[2].area, Rect::new(46, 2, 8, 1)); - assert_eq!(targets[3].area, Rect::new(54, 2, 9, 1)); + assert_eq!(targets[1].area, Rect::new(47, 2, 10, 1)); + assert_eq!(targets[2].area, Rect::new(57, 2, 9, 1)); + assert_eq!( + targets[2].action, + UiClickAction::SetScreen(ActiveScreen::ApiStat) + ); + assert_eq!(targets[3].area, Rect::new(66, 2, 8, 1)); + assert_eq!(targets[4].area, Rect::new(74, 2, 9, 1)); + } + + #[test] + fn api_stats_outer_title_identifies_openai_as_the_source() { + assert_eq!( + app_title(ActiveScreen::ApiStat), + format!(" comon :: {} :: SOURCE OPENAI ", env!("CARGO_PKG_VERSION")) + ); + assert_eq!( + app_title(ActiveScreen::Usage), + format!(" comon :: {} ", env!("CARGO_PKG_VERSION")) + ); + let (navigation, targets) = + navigation_title(Rect::new(0, 0, 80, 24), ActiveScreen::ApiStat); + assert_eq!(navigation.width(), 44); + assert_eq!(targets.len(), 5); } #[test] @@ -3798,6 +5821,171 @@ mod tests { } } + #[test] + fn api_stats_summary_respects_display_style() { + let system_locale = crate::locale::SystemLocale::default(); + let classic = DisplayFormatter::new(DisplayStyle::Classic, &system_locale); + let compact = DisplayFormatter::new(DisplayStyle::SystemCompact, &system_locale); + let full = DisplayFormatter::new(DisplayStyle::SystemFull, &system_locale); + + assert_eq!( + format_optional_account_tokens(Some(8_206_591_409), classic), + "8,206,591,409" + ); + assert_eq!( + format_optional_account_tokens(Some(8_206_591_409), compact), + "8.21B" + ); + assert_eq!( + format_optional_account_tokens(Some(8_206_591_409), full), + "8 206 591 409" + ); + assert_eq!(format_account_duration(42_168), "11h 42m 48s"); + } + + #[test] + fn api_stats_grouping_sums_reported_days_by_local_period() { + let buckets = vec![ + crate::codex_rpc::DailyUsageBucket { + start_date: "2026-07-31".to_string(), + tokens: 10, + }, + crate::codex_rpc::DailyUsageBucket { + start_date: "2026-08-01".to_string(), + tokens: 20, + }, + crate::codex_rpc::DailyUsageBucket { + start_date: "2026-08-03".to_string(), + tokens: 30, + }, + ]; + + let days = aggregate_api_stat_points(&buckets, ApiStatGrouping::Day, Weekday::Mon); + assert_eq!(days.len(), 3); + let weeks = aggregate_api_stat_points(&buckets, ApiStatGrouping::Week, Weekday::Mon); + assert_eq!(weeks.len(), 2); + assert_eq!(weeks[0].start.to_string(), "2026-07-27"); + assert_eq!(weeks[0].end.to_string(), "2026-08-02"); + assert_eq!(weeks[0].tokens, 30); + assert_eq!(weeks[0].active_days, 2); + assert_eq!(weeks[1].tokens, 30); + + let months = aggregate_api_stat_points(&buckets, ApiStatGrouping::Month, Weekday::Mon); + assert_eq!(months.len(), 2); + assert_eq!(months[0].start.to_string(), "2026-07-01"); + assert_eq!(months[0].end.to_string(), "2026-07-31"); + assert_eq!(months[0].tokens, 10); + assert_eq!(months[1].tokens, 50); + } + + #[test] + fn api_stats_week_grouping_is_always_iso_monday() { + let buckets = vec![crate::codex_rpc::DailyUsageBucket { + start_date: "2026-08-02".to_string(), + tokens: 42, + }]; + let monday = aggregate_api_stat_points(&buckets, ApiStatGrouping::Week, Weekday::Mon); + let sunday = aggregate_api_stat_points(&buckets, ApiStatGrouping::Week, Weekday::Sun); + assert_eq!(monday[0].start.to_string(), "2026-07-27"); + assert_eq!(sunday[0].start.to_string(), "2026-07-27"); + } + + #[test] + fn api_stats_week_label_keeps_both_months() { + let locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(DisplayStyle::Classic, &locale); + let point = ApiStatPoint { + start: NaiveDate::from_ymd_opt(2026, 3, 30).unwrap(), + end: NaiveDate::from_ymd_opt(2026, 4, 5).unwrap(), + tokens: 42, + active_days: 1, + }; + + assert_eq!( + format_api_stat_point_label(&point, ApiStatGrouping::Week, formatter), + "W14 (Mar 30-Apr 5)" + ); + } + + #[test] + fn usage_week_grouping_uses_iso_week_number_and_range() { + let days = vec![ + UsageDay { + day: "2026-04-05".to_string(), + input_tokens: 10, + cached_input_tokens: 2, + total_tokens: 12, + agent_time_ms: 100, + agent_runs: 1, + }, + UsageDay { + day: "2026-04-06".to_string(), + input_tokens: 20, + cached_input_tokens: 3, + total_tokens: 24, + agent_time_ms: 200, + agent_runs: 2, + }, + ]; + let grouped = aggregate_usage_days(&days, ChartRange::Week); + assert_eq!(grouped.len(), 2); + assert_eq!(grouped[0].day, "2026-03-30"); + assert_eq!(grouped[1].day, "2026-04-06"); + + let locale = crate::locale::SystemLocale::default(); + let formatter = DisplayFormatter::new(DisplayStyle::Classic, &locale); + let label = format_usage_period_label( + NaiveDate::from_ymd_opt(2026, 3, 30).unwrap(), + ChartRange::Week, + formatter, + false, + ); + assert_eq!(label, "W14 (Mar 30-Apr 5)"); + } + + #[test] + fn chart_scrollbar_reserves_only_the_requested_edge() { + let area = Rect::new(10, 20, 40, 12); + assert_eq!( + reserve_chart_scrollbar(area, ChartScrollbarAxis::Vertical, true), + (Rect::new(10, 20, 38, 12), Some(Rect::new(49, 20, 1, 12))) + ); + assert_eq!( + reserve_chart_scrollbar(area, ChartScrollbarAxis::Horizontal, true), + (Rect::new(10, 20, 40, 10), Some(Rect::new(10, 31, 40, 1))) + ); + assert_eq!( + reserve_chart_scrollbar(area, ChartScrollbarAxis::Vertical, false), + (area, None) + ); + } + + #[test] + fn scrollbar_thumbs_use_inverted_spaces() { + let (horizontal_char, horizontal_style) = chart_scrollbar_cell(true); + assert_eq!(horizontal_char, ' '); + assert_eq!(horizontal_style.bg, Some(Color::White)); + + let (vertical_char, vertical_style) = chart_scrollbar_cell(true); + assert_eq!(vertical_char, ' '); + assert_eq!(vertical_style.bg, Some(Color::White)); + + let (track_char, track_style) = chart_scrollbar_cell(false); + assert_eq!(track_char, '.'); + assert_eq!(track_style.fg, Some(Color::DarkGray)); + } + + #[test] + fn vertical_bar_capacity_prefers_wider_columns() { + assert_eq!(preferred_vertical_bar_width(40), 1); + assert_eq!(preferred_vertical_bar_width(80), 2); + assert_eq!(preferred_vertical_bar_width(160), 3); + assert_eq!(vertical_bar_capacity(40), 20); + assert_eq!(vertical_bar_capacity(80), 27); + assert_eq!(vertical_bar_capacity(160), 40); + assert_eq!(compute_bar_layout(Rect::new(0, 0, 160, 10), 40), (3, 1)); + } + #[test] fn navigation_title_hides_tabs_before_they_touch_the_version() { let (title, targets) = navigation_title(Rect::new(0, 0, 52, 10), ActiveScreen::Usage); diff --git a/src/usage/mod.rs b/src/usage/mod.rs index 6dc724c..6e5f5d7 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -1,6 +1,6 @@ use crate::locale::{DisplayFormatter, DisplayStyle}; use anyhow::{Context, Result}; -use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Utc, Weekday}; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Utc, Weekday}; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -18,7 +18,7 @@ const DEFAULT_MAX_SESSION_FILES_SCANNED: usize = 10_000; const DEFAULT_MAX_JSONL_LINE_BYTES: usize = 512 * 1024; const DEFAULT_SCAN_TIME_BUDGET_MS: u64 = 1500; const MAX_DISTINCT_MODELS: usize = 5_000; -const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 4; +const SCAN_CACHE_DB_SCHEMA_VERSION: i64 = 6; const FORK_REPLAY_END_GAP_MS: i64 = 1_000; const FORK_REPLAY_NO_TOKEN_GRACE_MS: i64 = 2_000; pub const DEFAULT_SCAN_CACHE_MAX_ENTRIES: usize = 50_000; @@ -59,10 +59,36 @@ pub enum UsageMetric { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChartRange { + Day, Week, Month, } +impl ChartRange { + pub fn toggled(self) -> Self { + match self { + Self::Day => Self::Week, + Self::Week => Self::Month, + Self::Month => Self::Day, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageZone { + Local, + Utc, +} + +impl UsageZone { + pub fn toggled(self) -> Self { + match self { + Self::Local => Self::Utc, + Self::Utc => Self::Local, + } + } +} + pub fn format_compact_kmb(value: u64, max_width: u16, formatter: DisplayFormatter<'_>) -> String { // Examples (depending on width): // 1234 -> 1.23K / 1.2K / 1K @@ -201,11 +227,18 @@ pub struct LocalUsageSnapshot { pub days: Vec, pub totals: UsageTotalsTokens, pub top_models: Vec, + pub utc_days: Vec, + pub utc_totals: UsageTotalsTokens, + pub utc_top_models: Vec, pub activity_first_weekday: Weekday, pub project_activity: Vec, // Number of session files that were identified as belonging to the selected workspace filter. // When no workspace filter is used, this is 0. pub matched_session_files: u32, + pub scan_total_files: usize, + pub scan_indexed_files: usize, + pub scan_pending_files: usize, + pub scan_processed_bytes: u64, } #[derive(Debug, Clone)] @@ -221,20 +254,33 @@ pub struct UsageTotalsView { } impl LocalUsageSnapshot { - pub fn last7_days(&self) -> Vec { - self.days - .iter() - .rev() - .take(7) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect() + pub fn days_for_zone(&self, zone: UsageZone) -> &[UsageDay] { + match zone { + UsageZone::Local => &self.days, + UsageZone::Utc => &self.utc_days, + } + } + + pub fn totals_for_zone(&self, zone: UsageZone) -> &UsageTotalsTokens { + match zone { + UsageZone::Local => &self.totals, + UsageZone::Utc => &self.utc_totals, + } + } + + pub fn top_models_for_zone(&self, zone: UsageZone) -> &[LocalUsageModel] { + match zone { + UsageZone::Local => &self.top_models, + UsageZone::Utc => &self.utc_top_models, + } + } + + pub fn last7_days_for_zone(&self, zone: UsageZone) -> Vec { + self.last_n_days_for_zone(zone, 7) } - pub fn last_n_days(&self, n: usize) -> Vec { - self.days + pub fn last_n_days_for_zone(&self, zone: UsageZone, n: usize) -> Vec { + self.days_for_zone(zone) .iter() .rev() .take(n) @@ -245,21 +291,23 @@ impl LocalUsageSnapshot { .collect() } - pub fn totals_view( + pub fn totals_view_for_zone( &self, metric: UsageMetric, formatter: DisplayFormatter<'_>, + zone: UsageZone, ) -> UsageTotalsView { - let last7 = self.last7_days(); + let totals = self.totals_for_zone(zone); + let last7 = self.last7_days_for_zone(zone); + let last30 = self.last_n_days_for_zone(zone, 30); let last7_agent_ms: i64 = last7.iter().map(|d| d.agent_time_ms).sum(); - let last30_agent_ms: i64 = self.days.iter().map(|d| d.agent_time_ms).sum(); + let last30_agent_ms: i64 = last30.iter().map(|d| d.agent_time_ms).sum(); let last7_runs: i64 = last7.iter().map(|d| d.agent_runs).sum(); - let last30_runs: i64 = self.days.iter().map(|d| d.agent_runs).sum(); + let last30_runs: i64 = last30.iter().map(|d| d.agent_runs).sum(); let (peak_day, _peak_value, peak_sub) = match metric { UsageMetric::Tokens => { - let peak = self - .days + let peak = last30 .iter() .max_by_key(|d| d.total_tokens) .filter(|d| d.total_tokens > 0); @@ -271,8 +319,7 @@ impl LocalUsageSnapshot { (peak_day, peak_tokens, sub) } UsageMetric::Time => { - let peak = self - .days + let peak = last30 .iter() .max_by_key(|d| d.agent_time_ms) .filter(|d| d.agent_time_ms > 0); @@ -284,8 +331,7 @@ impl LocalUsageSnapshot { (peak_day, peak_ms, sub) } UsageMetric::Runs => { - let peak = self - .days + let peak = last30 .iter() .max_by_key(|d| d.agent_runs) .filter(|d| d.agent_runs > 0); @@ -302,31 +348,28 @@ impl LocalUsageSnapshot { UsageMetric::Tokens => UsageTotalsView { last7_primary_label: format!( "{} tokens", - format_tokens_compact(self.totals.last7_days_tokens, formatter) + format_tokens_compact(totals.last7_days_tokens, formatter) ), last30_primary_label: format!( "{} tokens", - format_tokens_compact(self.totals.last30_days_tokens, formatter) - ), - avg_primary_label: format_tokens_compact( - self.totals.average_daily_tokens, - formatter, + format_tokens_compact(totals.last30_days_tokens, formatter) ), + avg_primary_label: format_tokens_compact(totals.average_daily_tokens, formatter), cache_label: format!( "{}%", - formatter.format_one_decimal(self.totals.cache_hit_rate_percent) + formatter.format_one_decimal(totals.cache_hit_rate_percent) ), - total_label: format_tokens_overview(self.totals.last30_days_tokens, formatter), + total_label: format_tokens_overview(totals.last30_days_tokens, formatter), runs_label: format!("{} runs", format_count(last7_runs, formatter)), peak_day_label: self - .totals + .totals_for_zone(zone) .peak_day .as_deref() .map(|day| format_day_short(day, formatter)) .unwrap_or(peak_day), peak_sub_label: format!( "{} tokens", - format_tokens_compact(self.totals.peak_day_tokens, formatter) + format_tokens_compact(totals.peak_day_tokens, formatter) ), }, UsageMetric::Time => { @@ -383,6 +426,12 @@ struct UsageTotals { output: i64, } +impl UsageTotals { + fn any_positive(self) -> bool { + self.input > 0 || self.cached > 0 || self.output > 0 + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] struct ParserState { #[serde(default)] @@ -395,6 +444,12 @@ struct ParserState { first_session_meta_seen: bool, #[serde(default)] fork_replay: ForkReplayState, + #[serde(default)] + fork_parent_id: Option, + #[serde(default)] + fork_baseline: Option, + #[serde(default)] + fork_live_started: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] @@ -442,6 +497,23 @@ struct FileScanSummary { fully_parsed: bool, daily: HashMap, model_totals_by_day: HashMap>, + unresolved_fork: bool, +} + +#[derive(Debug, Clone, Default)] +struct ForkResolution { + parent_id: Option, + baseline: Option, +} + +impl ForkResolution { + fn is_fork(&self) -> bool { + self.parent_id.is_some() + } + + fn unresolved(&self) -> bool { + self.is_fork() && self.baseline.is_none() + } } #[derive(Debug, Clone, Default)] @@ -493,13 +565,16 @@ pub fn compute_snapshot( let days = days.clamp(1, 90); let sessions_root = codex_home.join("sessions"); - let chart_day_keys = make_day_keys(days); - let chart_day_filter: HashSet = chart_day_keys.iter().cloned().collect(); + // The configured window controls summary cards and model shares. Charts are + // expanded to the complete indexed history after cached rows are applied. + let summary_day_keys = make_day_keys_for_zone(days, UsageZone::Local); + let utc_summary_day_keys = make_day_keys_for_zone(days, UsageZone::Utc); + let summary_day_filter: HashSet = summary_day_keys.iter().cloned().collect(); + let utc_summary_day_filter: HashSet = utc_summary_day_keys.iter().cloned().collect(); let activity_first_weekday = system_first_weekday(); let activity_day_keys = make_activity_day_keys(activity_first_weekday); - let activity_oldest_day = activity_day_keys.first().cloned(); let mut scan_day_keys = activity_day_keys.clone(); - for day_key in &chart_day_keys { + for day_key in &summary_day_keys { if !scan_day_keys.contains(day_key) { scan_day_keys.push(day_key.clone()); } @@ -508,18 +583,30 @@ pub fn compute_snapshot( .iter() .map(|key| (key.clone(), DailyTotals::default())) .collect(); + let mut utc_daily: HashMap = utc_summary_day_keys + .iter() + .map(|key| (key.clone(), DailyTotals::default())) + .collect(); let mut model_totals: HashMap = HashMap::new(); + let mut utc_model_totals: HashMap = HashMap::new(); let mut project_activity: HashMap = HashMap::new(); if !sessions_root.exists() { return Ok(build_snapshot( - chart_day_keys, + summary_day_keys, daily, model_totals, + utc_summary_day_keys, + utc_daily, + utc_model_totals, 0, activity_first_weekday, activity_day_keys, project_activity, + 0, + 0, + 0, + 0, )); } @@ -532,86 +619,115 @@ pub fn compute_snapshot( .then_with(|| a.len.cmp(&b.len)) }); - // Prefer scanning by file mtime instead of directory date: long-running sessions can live in an - // older day folder but still accrue usage today. - let cutoff_epoch_secs = if limits.full_scan { - None + let mut matched_session_files: u32 = 0; + let mut scan_cache_db = scan_cache_db_path + .map(open_or_init_scan_cache_db) + .transpose()?; + let candidate_paths: Vec = candidates + .iter() + .map(|candidate| candidate.path.to_string_lossy().to_string()) + .collect(); + let (mut scan_cache_store, mut removed_cache_paths) = if let Some(db) = scan_cache_db.as_ref() { + load_scan_cache_store(db)? } else { - activity_oldest_day - .as_deref() - .and_then(epoch_secs_for_local_day_start) - .or_else(|| { - SystemTime::now() - .checked_sub(StdDuration::from_secs( - (ACTIVITY_TIMELINE_DAYS as u64).saturating_mul(24 * 60 * 60), - )) - .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs()) - }) + (ScanCacheStore::default(), HashSet::new()) }; + if scan_cache_db.is_some() { + let valid_paths: HashSet<&str> = + candidate_paths.iter().map(|value| value.as_str()).collect(); + scan_cache_store.entries.retain(|file_path, _| { + let keep = valid_paths.contains(file_path.as_str()); + if !keep { + removed_cache_paths.insert(file_path.clone()); + } + keep + }); + } + let force_reparse_all = limits.full_scan && limits.scan_time_budget_ms == 0; - let mut planned_indices: Vec = Vec::new(); - let mut planned_total_bytes: u64 = 0; - let mut planned_files: usize = 0; - for (idx, candidate) in candidates.iter().enumerate() { - if let (Some(cutoff), Some(modified)) = (cutoff_epoch_secs, candidate.modified_epoch_secs) { - if modified < cutoff { - continue; + // Index every local session eventually. The normal scan budgets keep each + // refresh bounded; `full_scan` still processes the whole backlog at once. + let eligible_indices: Vec = (0..candidates.len()).collect(); + let mut work_indices: Vec = eligible_indices + .iter() + .copied() + .filter(|idx| { + if force_reparse_all { + return true; } + let key = &candidate_paths[*idx]; + scan_cache_store + .entries + .get(key) + .is_none_or(|entry| !cache_entry_matches_candidate(entry, &candidates[*idx])) + }) + .collect(); + + // Changed or partially parsed files are latency-sensitive. Uncached files form a + // deterministic backlog: once one is cached it drops out of this list, so later + // refreshes naturally advance instead of rescanning the same newest files forever. + work_indices.sort_by_key(|idx| { + let key = &candidate_paths[*idx]; + match scan_cache_store.entries.get(key) { + // Appended live sessions first, then brand-new sessions. Large or + // unresolved partial files rotate only after current usage has had + // a chance to enter the cache. + Some(entry) if entry.fully_parsed => (0_u8, entry.updated_at), + None => (1_u8, i64::MIN), + Some(entry) => (2_u8, entry.updated_at), } + }); + + let mut planned_indices: Vec = Vec::new(); + let mut planned_total_bytes: u64 = 0; + for idx in work_indices { if limits.full_scan { planned_indices.push(idx); continue; } - if planned_files >= limits.max_session_files_scanned + if planned_indices.len() >= limits.max_session_files_scanned || planned_total_bytes >= limits.max_session_total_bytes { break; } - let candidate_weight = candidate.len.min(limits.max_session_file_bytes.max(1)); - if planned_files > 0 + let candidate = &candidates[idx]; + let already_read = scan_cache_store + .entries + .get(&candidate_paths[idx]) + .map(|entry| entry.file_offset.min(candidate.len)) + .unwrap_or(0); + let remaining = candidate.len.saturating_sub(already_read).max(1); + let candidate_weight = remaining.min(limits.max_session_file_bytes.max(1)); + if !planned_indices.is_empty() && planned_total_bytes.saturating_add(candidate_weight) > limits.max_session_total_bytes { continue; } planned_indices.push(idx); - planned_files += 1; planned_total_bytes = planned_total_bytes.saturating_add(candidate_weight); } - - let mut matched_session_files: u32 = 0; - let mut scan_cache_db = scan_cache_db_path - .map(open_or_init_scan_cache_db) - .transpose()?; - let candidate_paths: Vec = candidates - .iter() - .map(|candidate| candidate.path.to_string_lossy().to_string()) - .collect(); let planned_set: HashSet = planned_indices.iter().copied().collect(); - let (mut scan_cache_store, mut removed_cache_paths) = if let Some(db) = scan_cache_db.as_ref() { - load_scan_cache_store(db)? - } else { - (ScanCacheStore::default(), HashSet::new()) - }; - let mut dirty_cache_paths: HashSet = HashSet::new(); + let fork_resolutions = resolve_fork_baselines( + &candidates, + &candidate_paths, + &planned_indices, + &scan_cache_store, + limits.max_jsonl_line_bytes, + ); + // Parent baseline discovery is bounded by the planned fork set and runs in + // the background usage worker. Start the incremental file-parse budget only + // after it, otherwise a large parent can consume every refresh before even + // one cache row advances. let scan_deadline = if limits.scan_time_budget_ms == 0 { None } else { Instant::now().checked_add(StdDuration::from_millis(limits.scan_time_budget_ms)) }; + let mut dirty_cache_paths: HashSet = HashSet::new(); + let mut uncached_indexed_files = 0usize; if scan_cache_db.is_some() { - let valid_paths: HashSet<&str> = - candidate_paths.iter().map(|value| value.as_str()).collect(); - scan_cache_store.entries.retain(|file_path, _| { - let keep = valid_paths.contains(file_path.as_str()); - if !keep { - removed_cache_paths.insert(file_path.clone()); - } - keep - }); - for (idx, candidate) in candidates.iter().enumerate() { let candidate_key = &candidate_paths[idx]; let cached_entry = scan_cache_store.entries.get(candidate_key).cloned(); @@ -636,7 +752,10 @@ pub fn compute_snapshot( workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -650,6 +769,10 @@ pub fn compute_snapshot( limits.max_jsonl_line_bytes, cached_entry.as_ref(), scan_deadline, + fork_resolutions + .get(candidate_key) + .cloned() + .unwrap_or_default(), ) { Ok(parsed) => parsed, Err(_) => { @@ -659,7 +782,10 @@ pub fn compute_snapshot( workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -670,8 +796,12 @@ pub fn compute_snapshot( let entry = CachedFileScanEntry { size: candidate.len, modified_epoch_secs: candidate.modified_epoch_secs, - file_offset: parsed.file_offset.min(candidate.len), - fully_parsed: parsed.fully_parsed, + file_offset: if parsed.unresolved_fork { + 0 + } else { + parsed.file_offset.min(candidate.len) + }, + fully_parsed: parsed.fully_parsed && !parsed.unresolved_fork, session_cwd: parsed.session_cwd, parser_state: parsed.parser_state, daily: parsed.daily, @@ -689,7 +819,10 @@ pub fn compute_snapshot( workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -702,7 +835,10 @@ pub fn compute_snapshot( workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -715,7 +851,10 @@ pub fn compute_snapshot( workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -724,25 +863,43 @@ pub fn compute_snapshot( } else { for idx in planned_indices { let candidate = &candidates[idx]; - let parsed = - parse_file_summary(&candidate.path, limits.max_jsonl_line_bytes, None, None)?; + let parsed = parse_file_summary( + &candidate.path, + limits.max_jsonl_line_bytes, + None, + None, + fork_resolutions + .get(&candidate_paths[idx]) + .cloned() + .unwrap_or_default(), + )?; let entry = CachedFileScanEntry { size: candidate.len, modified_epoch_secs: candidate.modified_epoch_secs, - file_offset: parsed.file_offset.min(candidate.len), - fully_parsed: parsed.fully_parsed, + file_offset: if parsed.unresolved_fork { + 0 + } else { + parsed.file_offset.min(candidate.len) + }, + fully_parsed: parsed.fully_parsed && !parsed.unresolved_fork, session_cwd: parsed.session_cwd, parser_state: parsed.parser_state, daily: parsed.daily, model_totals_by_day: parsed.model_totals_by_day, updated_at: unix_time_seconds(), }; + if cache_entry_matches_candidate(&entry, candidate) { + uncached_indexed_files = uncached_indexed_files.saturating_add(1); + } apply_cached_file_entry( &entry, workspace_path, &mut daily, &mut model_totals, - &chart_day_filter, + &summary_day_filter, + &mut utc_daily, + &mut utc_model_totals, + &utc_summary_day_filter, &mut project_activity, &mut matched_session_files, ); @@ -764,14 +921,50 @@ pub fn compute_snapshot( ); } + let scan_total_files = eligible_indices.len(); + let scan_indexed_files = if scan_cache_db.is_some() { + eligible_indices + .iter() + .filter(|idx| { + scan_cache_store + .entries + .get(&candidate_paths[**idx]) + .is_some_and(|entry| cache_entry_matches_candidate(entry, &candidates[**idx])) + }) + .count() + } else { + uncached_indexed_files + }; + let scan_pending_files = scan_total_files.saturating_sub(scan_indexed_files); + let scan_processed_bytes = eligible_indices + .iter() + .filter_map(|idx| { + scan_cache_store + .entries + .get(&candidate_paths[*idx]) + .map(|entry| entry.file_offset.min(candidates[*idx].len)) + }) + .fold(0_u64, u64::saturating_add); + + let chart_day_keys = make_complete_chart_day_keys(&daily, UsageZone::Local, &summary_day_keys); + let utc_chart_day_keys = + make_complete_chart_day_keys(&utc_daily, UsageZone::Utc, &utc_summary_day_keys); + Ok(build_snapshot( chart_day_keys, daily, model_totals, + utc_chart_day_keys, + utc_daily, + utc_model_totals, matched_session_files, activity_first_weekday, activity_day_keys, project_activity, + scan_total_files, + scan_indexed_files, + scan_pending_files, + scan_processed_bytes, )) } @@ -833,6 +1026,196 @@ fn collect_session_file_candidates(sessions_root: &Path) -> Vec Option { + let stem = path.file_stem()?.to_str()?; + let tail = stem; + if tail.len() < 36 { + return None; + } + let candidate = &tail[tail.len() - 36..]; + let valid = candidate.chars().enumerate().all(|(idx, ch)| match idx { + 8 | 13 | 18 | 23 => ch == '-', + _ => ch.is_ascii_hexdigit(), + }); + valid.then(|| candidate.to_string()) +} + +fn read_fork_metadata(path: &Path, max_jsonl_line_bytes: usize) -> Option<(String, i64)> { + let file = File::open(path).ok()?; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let bytes = reader.read_line(&mut line).ok()?; + if bytes == 0 || line.len() > max_jsonl_line_bytes { + return None; + } + let value = serde_json::from_str::(&line).ok()?; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + return None; + } + let payload = value.get("payload")?.as_object()?; + let parent_id = payload.get("forked_from_id")?.as_str()?.to_string(); + let timestamp_ms = read_timestamp_ms(&value) + .or_else(|| payload.get("timestamp").and_then(parse_timestamp_value_ms))?; + Some((parent_id, timestamp_ms)) +} + +fn resolve_fork_baselines( + candidates: &[SessionFileCandidate], + candidate_paths: &[String], + planned_indices: &[usize], + cache: &ScanCacheStore, + max_jsonl_line_bytes: usize, +) -> HashMap { + let id_to_path: HashMap = candidates + .iter() + .filter_map(|candidate| { + session_id_from_path(&candidate.path).map(|id| (id, candidate.path.as_path())) + }) + .collect(); + let mut resolutions = HashMap::::new(); + let mut requests = HashMap::>::new(); + + for idx in planned_indices { + let candidate = &candidates[*idx]; + let key = candidate_paths[*idx].clone(); + let Some((parent_id, fork_timestamp_ms)) = + read_fork_metadata(&candidate.path, max_jsonl_line_bytes) + else { + continue; + }; + let cached_baseline = cache.entries.get(&key).and_then(|entry| { + (entry.parser_state.fork_parent_id.as_deref() == Some(parent_id.as_str())) + .then_some(entry.parser_state.fork_baseline) + .flatten() + }); + resolutions.insert( + key.clone(), + ForkResolution { + parent_id: Some(parent_id.clone()), + baseline: cached_baseline, + }, + ); + if cached_baseline.is_none() { + requests + .entry(parent_id) + .or_default() + .push((key, fork_timestamp_ms)); + } + } + + for (parent_id, mut parent_requests) in requests { + let Some(parent_path) = id_to_path.get(&parent_id).copied() else { + continue; + }; + parent_requests.sort_by_key(|(_, timestamp_ms)| *timestamp_ms); + let resolved = scan_parent_baselines(parent_path, &parent_requests, max_jsonl_line_bytes); + for (child_path, baseline) in resolved { + if let Some(resolution) = resolutions.get_mut(&child_path) { + resolution.baseline = baseline; + } + } + } + + resolutions +} + +fn scan_parent_baselines( + parent_path: &Path, + requests: &[(String, i64)], + max_jsonl_line_bytes: usize, +) -> Vec<(String, Option)> { + let mut out = Vec::with_capacity(requests.len()); + let Ok(file) = File::open(parent_path) else { + return requests + .iter() + .map(|(child, _)| (child.clone(), None)) + .collect(); + }; + let mut reader = BufReader::new(file); + let mut request_idx = 0usize; + let mut totals: Option = None; + let mut line = String::new(); + let mut reached_eof = false; + + loop { + line.clear(); + let Ok(bytes) = reader.read_line(&mut line) else { + break; + }; + if bytes == 0 { + reached_eof = true; + break; + } + if line.len() > max_jsonl_line_bytes || !line.contains("token_count") { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let Some(timestamp_ms) = read_timestamp_ms(&value) else { + continue; + }; + while request_idx < requests.len() && requests[request_idx].1 < timestamp_ms { + out.push(( + requests[request_idx].0.clone(), + Some(totals.unwrap_or_default()), + )); + request_idx += 1; + } + let payload = value.get("payload").and_then(Value::as_object); + if payload + .and_then(|payload| payload.get("type")) + .and_then(Value::as_str) + != Some("token_count") + { + continue; + } + let info = payload + .and_then(|payload| payload.get("info")) + .and_then(Value::as_object); + let Some(info) = info else { + continue; + }; + if let Some(total) = find_usage_map(info, &["total_token_usage", "totalTokenUsage"]) { + totals = Some(UsageTotals { + input: read_i64(total, &["input_tokens", "inputTokens"]), + cached: read_i64( + total, + &[ + "cached_input_tokens", + "cache_read_input_tokens", + "cachedInputTokens", + "cacheReadInputTokens", + ], + ), + output: read_i64(total, &["output_tokens", "outputTokens"]), + }); + } else if let Some(last) = find_usage_map(info, &["last_token_usage", "lastTokenUsage"]) { + let current = totals.get_or_insert_with(UsageTotals::default); + current.input += read_i64(last, &["input_tokens", "inputTokens"]); + current.cached += read_i64( + last, + &[ + "cached_input_tokens", + "cache_read_input_tokens", + "cachedInputTokens", + "cacheReadInputTokens", + ], + ); + current.output += read_i64(last, &["output_tokens", "outputTokens"]); + } + } + + while request_idx < requests.len() { + out.push(( + requests[request_idx].0.clone(), + reached_eof.then_some(totals.unwrap_or_default()), + )); + request_idx += 1; + } + out +} + fn cache_entry_matches_candidate( entry: &CachedFileScanEntry, candidate: &SessionFileCandidate, @@ -843,22 +1226,54 @@ fn cache_entry_matches_candidate( && entry.file_offset >= candidate.len } +#[allow(clippy::too_many_arguments)] fn build_snapshot( day_keys: Vec, daily: HashMap, model_totals: HashMap, + utc_day_keys: Vec, + utc_daily: HashMap, + utc_model_totals: HashMap, matched_session_files: u32, activity_first_weekday: Weekday, activity_day_keys: Vec, project_activity: HashMap, + scan_total_files: usize, + scan_indexed_files: usize, + scan_pending_files: usize, + scan_processed_bytes: u64, ) -> LocalUsageSnapshot { + let (days, totals, top_models) = build_zone_snapshot(day_keys, daily, model_totals); + let (utc_days, utc_totals, utc_top_models) = + build_zone_snapshot(utc_day_keys, utc_daily, utc_model_totals); + + LocalUsageSnapshot { + days, + totals, + top_models, + utc_days, + utc_totals, + utc_top_models, + activity_first_weekday, + project_activity: build_project_activity(activity_day_keys, project_activity), + matched_session_files, + scan_total_files, + scan_indexed_files, + scan_pending_files, + scan_processed_bytes, + } +} + +fn build_zone_snapshot( + day_keys: Vec, + daily: HashMap, + model_totals: HashMap, +) -> (Vec, UsageTotalsTokens, Vec) { let mut days: Vec = Vec::with_capacity(day_keys.len()); - let mut total_tokens = 0i64; for day_key in &day_keys { let totals = daily.get(day_key).copied().unwrap_or_default(); let total = totals.input + totals.output; - total_tokens += total; days.push(UsageDay { day: day_key.clone(), input_tokens: totals.input, @@ -869,7 +1284,9 @@ fn build_snapshot( }); } + let last30 = days.iter().rev().take(30).cloned().collect::>(); let last7 = days.iter().rev().take(7).cloned().collect::>(); + let total_tokens: i64 = last30.iter().map(|day| day.total_tokens).sum(); let last7_tokens: i64 = last7.iter().map(|day| day.total_tokens).sum(); let last7_input: i64 = last7.iter().map(|day| day.input_tokens).sum(); let last7_cached: i64 = last7.iter().map(|day| day.cached_input_tokens).sum(); @@ -886,7 +1303,7 @@ fn build_snapshot( 0.0 }; - let peak = days + let peak = last30 .iter() .max_by_key(|day| day.total_tokens) .filter(|day| day.total_tokens > 0); @@ -906,12 +1323,12 @@ fn build_snapshot( }, }) .collect(); - top_models.sort_by(|a, b| b.tokens.cmp(&a.tokens)); + top_models.sort_by_key(|model| std::cmp::Reverse(model.tokens)); top_models.truncate(4); - LocalUsageSnapshot { + ( days, - totals: UsageTotalsTokens { + UsageTotalsTokens { last7_days_tokens: last7_tokens, last30_days_tokens: total_tokens, average_daily_tokens, @@ -920,10 +1337,7 @@ fn build_snapshot( peak_day_tokens, }, top_models, - activity_first_weekday, - project_activity: build_project_activity(activity_day_keys, project_activity), - matched_session_files, - } + ) } fn build_project_activity( @@ -1003,12 +1417,16 @@ fn add_model_tokens_limited( } } +#[allow(clippy::too_many_arguments)] fn apply_cached_file_entry( entry: &CachedFileScanEntry, workspace_path: Option<&Path>, daily: &mut HashMap, model_totals: &mut HashMap, chart_day_filter: &HashSet, + utc_daily: &mut HashMap, + utc_model_totals: &mut HashMap, + utc_chart_day_filter: &HashSet, project_activity: &mut HashMap, matched_session_files: &mut u32, ) { @@ -1028,22 +1446,35 @@ fn apply_cached_file_entry( *matched_session_files = matched_session_files.saturating_add(1); } - for (day_key, totals) in &entry.daily { - if let Some(dst) = daily.get_mut(day_key) { - dst.input += totals.input; - dst.cached += totals.cached; - dst.output += totals.output; - dst.agent_ms += totals.agent_ms; - dst.agent_runs += totals.agent_runs; - } + for (cache_key, totals) in &entry.daily { + let Some((zone, day_key)) = split_cache_day_key(cache_key) else { + continue; + }; + let target = match zone { + UsageZone::Local => &mut *daily, + UsageZone::Utc => &mut *utc_daily, + }; + let dst = target.entry(day_key.to_string()).or_default(); + dst.input += totals.input; + dst.cached += totals.cached; + dst.output += totals.output; + dst.agent_ms += totals.agent_ms; + dst.agent_runs += totals.agent_runs; } - for (day_key, per_day_models) in &entry.model_totals_by_day { - if !chart_day_filter.contains(day_key) { + for (cache_key, per_day_models) in &entry.model_totals_by_day { + let Some((zone, day_key)) = split_cache_day_key(cache_key) else { + continue; + }; + let (filter, target) = match zone { + UsageZone::Local => (chart_day_filter, &mut *model_totals), + UsageZone::Utc => (utc_chart_day_filter, &mut *utc_model_totals), + }; + if !filter.contains(day_key) { continue; } for (model, tokens) in per_day_models { - add_model_tokens_limited(model_totals, model.clone(), *tokens); + add_model_tokens_limited(target, model.clone(), *tokens); } } @@ -1074,11 +1505,14 @@ fn apply_project_activity( builder.display_path = cwd.to_string(); } - for (day_key, totals) in entry_daily { + for (cache_key, totals) in entry_daily { + let Some((UsageZone::Local, day_key)) = split_cache_day_key(cache_key) else { + continue; + }; if !day_filter.contains_key(day_key) { continue; } - let dst = builder.daily.entry(day_key.clone()).or_default(); + let dst = builder.daily.entry(day_key.to_string()).or_default(); dst.input += totals.input; dst.cached += totals.cached; dst.output += totals.output; @@ -1092,6 +1526,7 @@ fn parse_file_summary( max_jsonl_line_bytes: usize, existing: Option<&CachedFileScanEntry>, deadline: Option, + fork_resolution: ForkResolution, ) -> Result { let meta = match std::fs::symlink_metadata(path) { Ok(m) => m, @@ -1162,12 +1597,26 @@ fn parse_file_summary( } else { ParserState::default() }; + if fork_resolution.is_fork() { + parser_state.fork_parent_id = fork_resolution.parent_id.clone(); + parser_state.fork_baseline = fork_resolution.baseline; + } + if fork_resolution.unresolved() { + return Ok(FileScanSummary { + parser_state, + unresolved_fork: true, + ..FileScanSummary::default() + }); + } let mut reader = BufReader::new(file); let mut previous_totals: Option = parser_state.previous_totals; let mut current_model: Option = parser_state.current_model.clone(); let mut last_activity_ms: Option = parser_state.last_activity_ms; let mut first_session_meta_seen = parser_state.first_session_meta_seen; let mut fork_replay = parser_state.fork_replay; + let uses_parent_baseline = fork_resolution.is_fork(); + let parent_baseline = fork_resolution.baseline; + let mut fork_live_started = parser_state.fork_live_started; let mut seen_runs: HashSet = HashSet::new(); let mut line = String::new(); let mut fully_parsed = true; @@ -1213,11 +1662,17 @@ fn parse_file_summary( }; let event_timestamp_ms = read_timestamp_ms(&value); - let skip_fork_replay = started_fork_replay - || fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms); + let skip_fork_replay = if uses_parent_baseline { + !fork_live_started + && (started_fork_replay + || fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms)) + } else { + started_fork_replay + || fork_replay_should_skip_event(&mut fork_replay, event_timestamp_ms) + }; if entry_type == "turn_context" { - if !skip_fork_replay { + if uses_parent_baseline || !skip_fork_replay { if let Some(model) = extract_model_from_turn_context(&value) { current_model = Some(model); } @@ -1242,9 +1697,7 @@ fn parse_file_summary( if payload_type == Some("agent_message") { if let Some(timestamp_ms) = event_timestamp_ms { if seen_runs.insert(timestamp_ms) { - if let Some(day_key) = day_key_for_timestamp_ms(timestamp_ms) { - daily.entry(day_key).or_default().agent_runs += 1; - } + add_agent_run(&mut daily, timestamp_ms); } track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); } @@ -1314,25 +1767,47 @@ fn parse_file_summary( if used_total { let prev = previous_totals.unwrap_or_default(); - delta = UsageTotals { - input: (input - prev.input).max(0), - cached: (cached - prev.cached).max(0), - output: (output - prev.output).max(0), - }; - previous_totals = Some(UsageTotals { + let current = UsageTotals { input, cached, output, - }); + }; + delta = if let Some(baseline) = parent_baseline { + UsageTotals { + input: (input - prev.input.max(baseline.input)).max(0), + cached: (cached - prev.cached.max(baseline.cached)).max(0), + output: (output - prev.output.max(baseline.output)).max(0), + } + } else { + UsageTotals { + input: (input - prev.input).max(0), + cached: (cached - prev.cached).max(0), + output: (output - prev.output).max(0), + } + }; + previous_totals = Some(current); } else { - let mut next = previous_totals.unwrap_or_default(); + let prev = previous_totals.unwrap_or_default(); + let mut next = prev; next.input += delta.input; next.cached += delta.cached; next.output += delta.output; + if let Some(baseline) = parent_baseline { + delta = UsageTotals { + input: (next.input - prev.input.max(baseline.input)).max(0), + cached: (next.cached - prev.cached.max(baseline.cached)).max(0), + output: (next.output - prev.output.max(baseline.output)).max(0), + }; + } previous_totals = Some(next); } - if skip_fork_replay { + if uses_parent_baseline && delta.any_positive() { + fork_live_started = true; + } + if (uses_parent_baseline && !fork_live_started) + || (!uses_parent_baseline && skip_fork_replay) + { note_fork_replay_token(&mut fork_replay); continue; } @@ -1342,19 +1817,28 @@ fn parse_file_summary( } let timestamp_ms = event_timestamp_ms; - if let Some(day_key) = timestamp_ms.and_then(day_key_for_timestamp_ms) { - let entry = daily.entry(day_key.clone()).or_default(); - let cached_clamped = delta.cached.min(delta.input); - entry.input += delta.input; - entry.cached += cached_clamped; - entry.output += delta.output; - + if let Some(timestamp_ms) = timestamp_ms { let model = current_model .clone() .or_else(|| extract_model_from_token_count(&value)) .unwrap_or_else(|| "unknown".to_string()); - let per_day_models = model_totals_by_day.entry(day_key).or_default(); - add_model_tokens_limited(per_day_models, model, delta.input + delta.output); + for zone in [UsageZone::Local, UsageZone::Utc] { + let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) else { + continue; + }; + let entry = daily.entry(day_key.clone()).or_default(); + let cached_clamped = delta.cached.min(delta.input); + entry.input += delta.input; + entry.cached += cached_clamped; + entry.output += delta.output; + + let per_day_models = model_totals_by_day.entry(day_key).or_default(); + add_model_tokens_limited( + per_day_models, + model.clone(), + delta.input + delta.output, + ); + } } if let Some(timestamp_ms) = timestamp_ms { @@ -1380,9 +1864,7 @@ fn parse_file_summary( if role == "assistant" { if let Some(timestamp_ms) = event_timestamp_ms { if seen_runs.insert(timestamp_ms) { - if let Some(day_key) = day_key_for_timestamp_ms(timestamp_ms) { - daily.entry(day_key).or_default().agent_runs += 1; - } + add_agent_run(&mut daily, timestamp_ms); } track_activity(&mut daily, &mut last_activity_ms, timestamp_ms); } @@ -1399,6 +1881,7 @@ fn parse_file_summary( parser_state.last_activity_ms = last_activity_ms; parser_state.first_session_meta_seen = first_session_meta_seen; parser_state.fork_replay = fork_replay; + parser_state.fork_live_started = fork_live_started; Ok(FileScanSummary { session_cwd, @@ -1407,6 +1890,7 @@ fn parse_file_summary( fully_parsed: fully_parsed && file_offset >= file_len, daily, model_totals_by_day, + unresolved_fork: false, }) } @@ -1497,6 +1981,17 @@ fn open_or_init_scan_cache_db(path: &Path) -> Result { if schema_version < 4 { invalidate_forked_session_cache_rows(&conn, path)?; } + if schema_version < 5 { + // v5 stores both local and UTC day buckets (L:/U: prefixes). Old + // unqualified rows cannot be converted without replaying timestamps. + conn.execute("DELETE FROM file_cache;", []) + .with_context(|| format!("Unable to rebuild v5 day buckets in {}", path.display()))?; + } else if schema_version < 6 { + // v6 applies replay-boundary detection even when a parent cumulative + // baseline is available. Reparse only fork rows so the first live + // response after the copied prefix contributes to run/activity data. + invalidate_forked_session_cache_rows(&conn, path)?; + } if schema_version < SCAN_CACHE_DB_SCHEMA_VERSION { conn.execute( "UPDATE cache_meta SET value = ?1 WHERE key = 'schema_version';", @@ -1582,9 +2077,8 @@ fn migrate_scan_cache_db_v1_to_v2(conn: &Connection, path: &Path) -> Result<()> } fn invalidate_forked_session_cache_rows(conn: &Connection, path: &Path) -> Result<()> { - // v3 and v4 change forked-session accounting semantics. Reusing older fork rows - // would keep inflated totals, but non-forked cache rows are still valid. One pass - // is sufficient even when upgrading across multiple schema versions at once. + // Fork replay/accounting semantics changed in v3, v4, and v6. Reusing older + // fork rows would preserve stale aggregates, while non-fork rows remain valid. let mut stmt = conn .prepare("SELECT file_path FROM file_cache;") .with_context(|| { @@ -2274,25 +2768,46 @@ fn track_activity( if let Some(prev_ms) = *last_activity_ms { let delta = timestamp_ms - prev_ms; if delta > 0 && delta <= MAX_ACTIVITY_GAP_MS { - if let Some(day_key) = day_key_for_timestamp_ms(timestamp_ms) { - daily.entry(day_key).or_default().agent_ms += delta; + for zone in [UsageZone::Local, UsageZone::Utc] { + if let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) { + daily.entry(day_key).or_default().agent_ms += delta; + } } } } *last_activity_ms = Some(timestamp_ms); } -fn day_key_for_timestamp_ms(timestamp_ms: i64) -> Option { +fn add_agent_run(daily: &mut HashMap, timestamp_ms: i64) { + for zone in [UsageZone::Local, UsageZone::Utc] { + if let Some(day_key) = cache_day_key_for_timestamp_ms(timestamp_ms, zone) { + daily.entry(day_key).or_default().agent_runs += 1; + } + } +} + +fn display_day_key_for_timestamp_ms(timestamp_ms: i64, zone: UsageZone) -> Option { let utc = Utc.timestamp_millis_opt(timestamp_ms).single()?; - Some(utc.with_timezone(&Local).format("%Y-%m-%d").to_string()) + Some(match zone { + UsageZone::Local => utc.with_timezone(&Local).format("%Y-%m-%d").to_string(), + UsageZone::Utc => utc.format("%Y-%m-%d").to_string(), + }) +} + +fn cache_day_key_for_timestamp_ms(timestamp_ms: i64, zone: UsageZone) -> Option { + let day = display_day_key_for_timestamp_ms(timestamp_ms, zone)?; + Some(match zone { + UsageZone::Local => format!("L:{day}"), + UsageZone::Utc => format!("U:{day}"), + }) } -fn epoch_secs_for_local_day_start(day_key: &str) -> Option { - let day = chrono::NaiveDate::parse_from_str(day_key, "%Y-%m-%d").ok()?; - let local = Local - .with_ymd_and_hms(day.year(), day.month(), day.day(), 0, 0, 0) - .single()?; - u64::try_from(local.timestamp()).ok() +fn split_cache_day_key(value: &str) -> Option<(UsageZone, &str)> { + if let Some(day) = value.strip_prefix("L:") { + Some((UsageZone::Local, day)) + } else { + value.strip_prefix("U:").map(|day| (UsageZone::Utc, day)) + } } fn extract_cwd(value: &Value) -> Option { @@ -2320,8 +2835,11 @@ fn normalize_project_key(path: &str) -> String { path.trim().to_lowercase() } -fn make_day_keys(days: u32) -> Vec { - let today = Local::now().date_naive(); +fn make_day_keys_for_zone(days: u32, zone: UsageZone) -> Vec { + let today = match zone { + UsageZone::Local => Local::now().date_naive(), + UsageZone::Utc => Utc::now().date_naive(), + }; (0..days) .rev() .map(|offset| { @@ -2331,6 +2849,48 @@ fn make_day_keys(days: u32) -> Vec { .collect() } +fn make_complete_chart_day_keys( + daily: &HashMap, + zone: UsageZone, + fallback: &[String], +) -> Vec { + let mut active_dates = daily.iter().filter_map(|(day, totals)| { + daily_has_activity(*totals) + .then(|| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) + .flatten() + }); + let Some(mut first) = active_dates.next() else { + return fallback.to_vec(); + }; + let mut last = first; + for date in active_dates { + first = first.min(date); + last = last.max(date); + } + + let today = match zone { + UsageZone::Local => Local::now().date_naive(), + UsageZone::Utc => Utc::now().date_naive(), + }; + last = last.max(today); + + let mut keys = Vec::with_capacity( + last.signed_duration_since(first) + .num_days() + .max(0) + .saturating_add(1) as usize, + ); + let mut date = first; + while date <= last { + keys.push(date.format("%Y-%m-%d").to_string()); + let Some(next) = date.checked_add_signed(Duration::days(1)) else { + break; + }; + date = next; + } + keys +} + pub fn system_first_weekday() -> Weekday { locale_region_from_env() .as_deref() @@ -2775,7 +3335,7 @@ mod tests { } #[test] - fn compute_snapshot_ignores_forked_session_replay_without_cache() { + fn compute_snapshot_marks_missing_fork_parent_partial_without_cache() { let root = make_temp_dir("fork-replay-no-cache"); let codex_home = root.join("codex"); let sessions_root = codex_home.join("sessions"); @@ -2790,20 +3350,18 @@ mod tests { let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) .expect("snapshot"); - assert_eq!( - snapshot.totals.last30_days_tokens, 220, - "only token deltas after the fork replay should count" - ); + assert_eq!(snapshot.totals.last30_days_tokens, 0); + assert_eq!(snapshot.scan_pending_files, 1); assert_eq!( snapshot.days.iter().map(|day| day.agent_runs).sum::(), - 1 + 0 ); let _ = std::fs::remove_dir_all(root); } #[test] - fn compute_snapshot_ignores_forked_session_replay_with_cache() { + fn compute_snapshot_keeps_missing_fork_parent_partial_with_cache() { let root = make_temp_dir("fork-replay-cache"); let codex_home = root.join("codex"); let sessions_root = codex_home.join("sessions"); @@ -2825,7 +3383,8 @@ mod tests { Some(cache_db_path.as_path()), ) .expect("first snapshot"); - assert_eq!(first.totals.last30_days_tokens, 220); + assert_eq!(first.totals.last30_days_tokens, 0); + assert_eq!(first.scan_pending_files, 1); let cached = compute_snapshot( 30, @@ -2835,14 +3394,183 @@ mod tests { Some(cache_db_path.as_path()), ) .expect("cached snapshot"); - assert_eq!(cached.totals.last30_days_tokens, 220); - assert_eq!(cached.days.iter().map(|day| day.agent_runs).sum::(), 1); + assert_eq!(cached.totals.last30_days_tokens, 0); + assert_eq!(cached.scan_pending_files, 1); + assert_eq!(cached.days.iter().map(|day| day.agent_runs).sum::(), 0); let _ = std::fs::remove_dir_all(root); } #[test] - fn compute_snapshot_ignores_delayed_fork_metadata_replay() { + fn compute_snapshot_uses_parent_totals_as_fork_baseline() { + let root = make_temp_dir("fork-parent-baseline"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + + let parent_id = "11111111-1111-4111-8111-111111111111"; + let child_id = "22222222-2222-4222-8222-222222222222"; + let parent_path = sessions_root.join(format!("rollout-parent-{parent_id}.jsonl")); + let child_path = sessions_root.join(format!("rollout-child-{child_id}.jsonl")); + let fork_ms = Utc::now().timestamp_millis() - Duration::hours(1).num_milliseconds(); + append_session_meta_line(&parent_path, fork_ms - 1_000, "/tmp/forked-project"); + append_total_token_line(&parent_path, fork_ms - 100, 1_500, 1_300, 130); + + append_json_line( + &child_path, + serde_json::json!({ + "type": "session_meta", + "timestamp": Utc.timestamp_millis_opt(fork_ms).single().unwrap().to_rfc3339(), + "payload": { + "id": child_id, + "forked_from_id": parent_id, + "timestamp": Utc.timestamp_millis_opt(fork_ms).single().unwrap().to_rfc3339(), + "cwd": "/tmp/forked-project" + } + }), + ); + // Copied replay response: it is inside the replay burst and must not + // become a new run in the child. + append_agent_message_line(&child_path, fork_ms + 50); + append_total_token_line(&child_path, fork_ms + 100, 1_500, 1_300, 130); + // Real child response precedes its token_count event. The one-second + // gap ends the copied replay prefix, so this must count as one run. + append_agent_message_line(&child_path, fork_ms + 1_500); + append_total_token_line(&child_path, fork_ms + 1_600, 1_700, 1_400, 150); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + assert_eq!(snapshot.totals.last30_days_tokens, 1_850); + assert_eq!(snapshot.utc_totals.last30_days_tokens, 1_850); + assert_eq!( + snapshot.days.iter().map(|day| day.agent_runs).sum::(), + 1 + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn cached_scanner_advances_through_backlog_across_refreshes() { + let root = make_temp_dir("scan-backlog"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let now_ms = Utc::now().timestamp_millis(); + for index in 0..3 { + write_token_file( + &sessions_root.join(format!("session-{index}.jsonl")), + now_ms + index, + 100 + index, + 20, + ); + } + let mut limits = default_test_limits(false); + limits.max_session_files_scanned = 1; + let cache_db_path = root.join("comon.db"); + + let first = compute_snapshot(30, &codex_home, None, limits, Some(&cache_db_path)) + .expect("first snapshot"); + assert_eq!(first.scan_indexed_files, 1); + assert_eq!(first.scan_pending_files, 2); + assert!(first.scan_processed_bytes > 0); + let second = compute_snapshot(30, &codex_home, None, limits, Some(&cache_db_path)) + .expect("second snapshot"); + assert_eq!(second.scan_indexed_files, 2); + assert!(second.scan_processed_bytes > first.scan_processed_bytes); + let third = compute_snapshot(30, &codex_home, None, limits, Some(&cache_db_path)) + .expect("third snapshot"); + assert_eq!(third.scan_indexed_files, 3); + assert_eq!(third.scan_pending_files, 0); + assert!(third.scan_processed_bytes > second.scan_processed_bytes); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn unresolved_partial_fork_does_not_starve_new_session() { + let root = make_temp_dir("scan-partial-priority"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let now_ms = Utc::now().timestamp_millis(); + let fork_path = sessions_root.join("forked.jsonl"); + write_forked_replay_file(&fork_path, now_ms); + + let mut limits = default_test_limits(false); + limits.max_session_files_scanned = 1; + let cache_db_path = root.join("comon.db"); + let first = compute_snapshot(30, &codex_home, None, limits, Some(&cache_db_path)) + .expect("first snapshot"); + assert_eq!(first.totals.last30_days_tokens, 0); + assert_eq!(first.scan_pending_files, 1); + + let normal_path = sessions_root.join("normal.jsonl"); + write_token_file(&normal_path, now_ms + 1_000, 100, 20); + + let second = compute_snapshot(30, &codex_home, None, limits, Some(&cache_db_path)) + .expect("second snapshot"); + assert_eq!(second.totals.last30_days_tokens, 120); + assert_eq!(second.scan_indexed_files, 1); + assert_eq!(second.scan_pending_files, 1); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn compute_snapshot_populates_local_and_utc_projections() { + let root = make_temp_dir("usage-zones"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let now_ms = Utc::now().timestamp_millis(); + write_token_file(&sessions_root.join("session.jsonl"), now_ms, 100, 20); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + assert_eq!(snapshot.totals.last30_days_tokens, 120); + assert_eq!(snapshot.utc_totals.last30_days_tokens, 120); + assert_eq!(snapshot.days.len(), snapshot.utc_days.len()); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn compute_snapshot_charts_all_indexed_days_but_keeps_thirty_day_summary() { + let root = make_temp_dir("usage-full-chart-history"); + let codex_home = root.join("codex"); + let sessions_root = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let now_ms = Utc::now().timestamp_millis(); + let old_ms = now_ms - Duration::days(45).num_milliseconds(); + write_token_file(&sessions_root.join("old.jsonl"), old_ms, 1_000, 20); + write_token_file(&sessions_root.join("recent.jsonl"), now_ms, 100, 20); + + let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) + .expect("snapshot"); + let old_day = Utc + .timestamp_millis_opt(old_ms) + .single() + .expect("old timestamp") + .format("%Y-%m-%d") + .to_string(); + assert!(snapshot.utc_days.len() >= 46); + assert_eq!( + snapshot + .utc_days + .iter() + .find(|day| day.day == old_day) + .map(|day| day.total_tokens), + Some(1_020) + ); + assert_eq!(snapshot.utc_totals.last30_days_tokens, 120); + assert_eq!(snapshot.utc_totals.peak_day_tokens, 120); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn compute_snapshot_keeps_delayed_missing_parent_partial() { let root = make_temp_dir("fork-replay-delayed-metadata"); let codex_home = root.join("codex"); let sessions_root = codex_home.join("sessions"); @@ -2857,18 +3585,19 @@ mod tests { let snapshot = compute_snapshot(30, &codex_home, None, default_test_limits(false), None) .expect("snapshot"); - assert_eq!(snapshot.totals.last30_days_tokens, 220); + assert_eq!(snapshot.totals.last30_days_tokens, 0); + assert_eq!(snapshot.scan_pending_files, 1); assert_eq!( snapshot.days.iter().map(|day| day.agent_runs).sum::(), - 1, - "the delayed outer session timestamp must not expose compressed replay runs" + 0, + "an unresolved parent must not expose compressed replay runs" ); let _ = std::fs::remove_dir_all(root); } #[test] - fn compute_snapshot_resumes_active_delayed_fork_replay_from_cache() { + fn compute_snapshot_does_not_guess_missing_parent_after_append() { let root = make_temp_dir("fork-replay-delayed-resume"); let codex_home = root.join("codex"); let sessions_root = codex_home.join("sessions"); @@ -2908,10 +3637,11 @@ mod tests { Some(cache_db_path.as_path()), ) .expect("resumed snapshot"); - assert_eq!(resumed.totals.last30_days_tokens, 220); + assert_eq!(resumed.totals.last30_days_tokens, 0); + assert_eq!(resumed.scan_pending_files, 1); assert_eq!( resumed.days.iter().map(|day| day.agent_runs).sum::(), - 1 + 0 ); let cached = compute_snapshot( @@ -2922,8 +3652,9 @@ mod tests { Some(cache_db_path.as_path()), ) .expect("cached resumed snapshot"); - assert_eq!(cached.totals.last30_days_tokens, 220); - assert_eq!(cached.days.iter().map(|day| day.agent_runs).sum::(), 1); + assert_eq!(cached.totals.last30_days_tokens, 0); + assert_eq!(cached.scan_pending_files, 1); + assert_eq!(cached.days.iter().map(|day| day.agent_runs).sum::(), 0); let _ = std::fs::remove_dir_all(root); } @@ -3363,7 +4094,7 @@ mod tests { } #[test] - fn open_or_init_scan_cache_db_v3_migration_preserves_nonforked_rows() { + fn open_or_init_scan_cache_db_rebuilds_v2_day_buckets() { let root = make_temp_dir("cache-migrate-v3"); let sessions_root = root.join("sessions"); std::fs::create_dir_all(&sessions_root).expect("create sessions root"); @@ -3414,16 +4145,13 @@ mod tests { let db = open_or_init_scan_cache_db(&db_path).expect("open and migrate"); let (store, _) = load_scan_cache_store(&db).expect("load migrated cache"); - let keep_key = keep_path.to_string_lossy().to_string(); - let forked_key = forked_path.to_string_lossy().to_string(); - assert!(store.entries.contains_key(&keep_key)); - assert!(!store.entries.contains_key(&forked_key)); + assert!(store.entries.is_empty()); let _ = std::fs::remove_dir_all(root); } #[test] - fn open_or_init_scan_cache_db_v4_reparses_only_forked_rows() { + fn open_or_init_scan_cache_db_rebuilds_v3_day_buckets() { let root = make_temp_dir("cache-migrate-v4"); let sessions_root = root.join("sessions"); std::fs::create_dir_all(&sessions_root).expect("create sessions root"); @@ -3473,10 +4201,7 @@ mod tests { let db = open_or_init_scan_cache_db(&db_path).expect("open and migrate"); let (store, _) = load_scan_cache_store(&db).expect("load migrated cache"); - let keep_key = keep_path.to_string_lossy().to_string(); - let forked_key = forked_path.to_string_lossy().to_string(); - assert!(store.entries.contains_key(&keep_key)); - assert!(!store.entries.contains_key(&forked_key)); + assert!(store.entries.is_empty()); let schema_version: i64 = db .conn @@ -3486,7 +4211,68 @@ mod tests { |row| row.get(0), ) .expect("read schema version"); - assert_eq!(schema_version, 4); + assert_eq!(schema_version, SCAN_CACHE_DB_SCHEMA_VERSION); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn open_or_init_scan_cache_db_v6_reparses_only_v5_fork_rows() { + let root = make_temp_dir("cache-migrate-v6"); + let sessions_root = root.join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("create sessions root"); + let keep_path = sessions_root.join("keep.jsonl"); + let forked_path = sessions_root.join("forked.jsonl"); + let now_ms = Utc::now().timestamp_millis(); + write_token_file(&keep_path, now_ms, 10, 5); + write_forked_replay_file(&forked_path, now_ms); + + let db_path = root.join("comon.db"); + let conn = Connection::open(&db_path).expect("open v5 db"); + conn.execute_batch( + " + CREATE TABLE cache_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + INSERT INTO cache_meta(key, value) VALUES('schema_version', 5); + CREATE TABLE file_cache ( + file_path TEXT PRIMARY KEY, + file_size INTEGER NOT NULL, + file_mtime INTEGER, + file_offset INTEGER NOT NULL DEFAULT 0, + fully_parsed INTEGER NOT NULL DEFAULT 1, + session_cwd TEXT, + parser_state_json TEXT NOT NULL DEFAULT '{}', + daily_json TEXT NOT NULL, + model_daily_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + ", + ) + .expect("create v5 schema"); + for path in [&keep_path, &forked_path] { + conn.execute( + " + INSERT INTO file_cache( + file_path, file_size, file_mtime, file_offset, fully_parsed, + session_cwd, parser_state_json, daily_json, model_daily_json, updated_at + ) VALUES(?1, 1, 1, 1, 1, '/tmp', '{}', '{}', '{}', 1); + ", + rusqlite::params![path.to_string_lossy().to_string()], + ) + .expect("insert cache row"); + } + drop(conn); + + let db = open_or_init_scan_cache_db(&db_path).expect("open and migrate"); + let (store, _) = load_scan_cache_store(&db).expect("load migrated cache"); + assert!(store + .entries + .contains_key(&keep_path.to_string_lossy().to_string())); + assert!(!store + .entries + .contains_key(&forked_path.to_string_lossy().to_string())); let _ = std::fs::remove_dir_all(root); }