From 95c33d3c04780df5082a88471d64b15a40fb0140 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:31:54 -0700 Subject: [PATCH] feat: add ecc2 budget alert thresholds --- ecc2/src/tui/dashboard.rs | 135 ++++++++++++++++++++++++++++++++------ ecc2/src/tui/widgets.rs | 69 +++++++++++++------ 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 842d820b..beff4af2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -102,6 +102,7 @@ pub struct Dashboard { selected_search_match: usize, session_table_state: TableState, last_cost_metrics_signature: Option<(u64, u128)>, + last_budget_alert_state: BudgetState, } #[derive(Debug, Default, PartialEq, Eq)] @@ -344,6 +345,7 @@ impl Dashboard { selected_search_match: 0, session_table_state, last_cost_metrics_signature: initial_cost_metrics_signature, + last_budget_alert_state: BudgetState::Normal, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_handoff_backlog_counts(); @@ -353,6 +355,7 @@ impl Dashboard { dashboard.sync_selected_messages(); dashboard.sync_selected_lineage(); dashboard.refresh_logs(); + dashboard.last_budget_alert_state = dashboard.aggregate_usage().overall_state; dashboard } @@ -989,16 +992,20 @@ impl Dashboard { " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), " v Toggle selected worktree diff in output pane".to_string(), - " c Show conflict-resolution protocol for selected conflicted worktree".to_string(), + " c Show conflict-resolution protocol for selected conflicted worktree" + .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), - " A Toggle search or timeline scope between selected session and all sessions".to_string(), - " o Toggle search agent filter between all agents and selected agent type".to_string(), + " A Toggle search or timeline scope between selected session and all sessions" + .to_string(), + " o Toggle search agent filter between all agents and selected agent type" + .to_string(), " m Merge selected ready worktree into base and clean it up".to_string(), " M Merge all ready inactive worktrees and clean them up".to_string(), " l Cycle pane layout and persist it".to_string(), " T Toggle theme and persist it".to_string(), - " t Toggle default worktree creation for new sessions and delegated work".to_string(), + " t Toggle default worktree creation for new sessions and delegated work" + .to_string(), " p Toggle daemon auto-dispatch policy and persist config".to_string(), " w Toggle daemon auto-merge for ready inactive worktrees".to_string(), " ,/. Decrease/increase auto-dispatch limit per lead".to_string(), @@ -1100,8 +1107,7 @@ impl Dashboard { pub fn begin_pane_command_mode(&mut self) { self.pane_command_mode = true; self.set_operator_note( - "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize" - .to_string(), + "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize".to_string(), ); } @@ -1165,7 +1171,6 @@ impl Dashboard { true } - pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -1648,6 +1653,7 @@ impl Dashboard { self.sync_selected_messages(); self.sync_selected_lineage(); self.refresh_logs(); + self.sync_budget_alerts(); } pub fn toggle_output_mode(&mut self) { @@ -4012,8 +4018,7 @@ impl Dashboard { )); lines.push(format!( "Tools {} | Files {}", - metrics.tool_calls, - metrics.files_changed, + metrics.tool_calls, metrics.files_changed, )); lines.push(format!( "Cost ${:.4} | Duration {}s", @@ -4080,15 +4085,56 @@ impl Dashboard { ) }; - match aggregate.overall_state { - BudgetState::Warning => text.push_str(" | Budget warning"), - BudgetState::OverBudget => text.push_str(" | Budget exceeded"), - _ => {} + if let Some(summary_suffix) = aggregate.overall_state.summary_suffix() { + text.push_str(" | "); + text.push_str(summary_suffix); } (text, aggregate.overall_state.style()) } + fn sync_budget_alerts(&mut self) { + let aggregate = self.aggregate_usage(); + let current_state = aggregate.overall_state; + if current_state == self.last_budget_alert_state { + return; + } + + let previous_state = self.last_budget_alert_state; + self.last_budget_alert_state = current_state; + + if current_state <= previous_state { + return; + } + + let Some(summary_suffix) = current_state.summary_suffix() else { + return; + }; + + let token_budget = if self.cfg.token_budget > 0 { + format!( + "{} / {}", + format_token_count(aggregate.total_tokens), + format_token_count(self.cfg.token_budget) + ) + } else { + format!("{} / no budget", format_token_count(aggregate.total_tokens)) + }; + let cost_budget = if self.cfg.cost_budget_usd > 0.0 { + format!( + "{} / {}", + format_currency(aggregate.total_cost_usd), + format_currency(self.cfg.cost_budget_usd) + ) + } else { + format!("{} / no budget", format_currency(aggregate.total_cost_usd)) + }; + + self.set_operator_note(format!( + "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" + )); + } + fn attention_queue_items(&self, limit: usize) -> Vec { let mut items = Vec::new(); let suppress_inbox_attention = self @@ -7033,10 +7079,60 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!( dashboard.aggregate_cost_summary_text(), - "Aggregate cost $8.25 / $10.00 | Budget warning" + "Aggregate cost $8.25 / $10.00 | Budget alert 75%" ); } + #[test] + fn aggregate_cost_summary_mentions_fifty_percent_alert() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 1_000, 5.0)]; + + assert_eq!( + dashboard.aggregate_cost_summary_text(), + "Aggregate cost $5.00 / $10.00 | Budget alert 50%" + ); + } + + #[test] + fn aggregate_cost_summary_mentions_ninety_percent_alert() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 1_000, 9.0)]; + + assert_eq!( + dashboard.aggregate_cost_summary_text(), + "Aggregate cost $9.00 / $10.00 | Budget alert 90%" + ); + } + + #[test] + fn sync_budget_alerts_sets_operator_note_when_threshold_is_crossed() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.token_budget = 1_000; + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 760, 2.0)]; + dashboard.last_budget_alert_state = BudgetState::Alert50; + + dashboard.sync_budget_alerts(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("Budget alert 75% | tokens 760 / 1,000 | cost $2.00 / $10.00") + ); + assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -8647,12 +8743,10 @@ diff --git a/src/next.rs b/src/next.rs ))); assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); - assert!( - dashboard - .operator_note - .as_deref() - .is_some_and(|note| note.contains("pane layout set to grid | saved to ")) - ); + assert!(dashboard + .operator_note + .as_deref() + .is_some_and(|note| note.contains("pane layout set to grid | saved to "))); } #[test] @@ -8961,6 +9055,7 @@ diff --git a/src/next.rs b/src/next.rs selected_search_match: 0, session_table_state, last_cost_metrics_signature: None, + last_budget_alert_state: BudgetState::Normal, } } diff --git a/ecc2/src/tui/widgets.rs b/ecc2/src/tui/widgets.rs index 784e4b50..370011b4 100644 --- a/ecc2/src/tui/widgets.rs +++ b/ecc2/src/tui/widgets.rs @@ -4,39 +4,53 @@ use ratatui::{ widgets::{Gauge, Paragraph, Widget}, }; -pub(crate) const WARNING_THRESHOLD: f64 = 0.8; +pub(crate) const ALERT_THRESHOLD_50: f64 = 0.50; +pub(crate) const ALERT_THRESHOLD_75: f64 = 0.75; +pub(crate) const ALERT_THRESHOLD_90: f64 = 0.90; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum BudgetState { Unconfigured, Normal, - Warning, + Alert50, + Alert75, + Alert90, OverBudget, } impl BudgetState { - pub(crate) const fn is_warning(self) -> bool { - matches!(self, Self::Warning | Self::OverBudget) - } - fn badge(self) -> Option<&'static str> { match self { - Self::Warning => Some("warning"), + Self::Alert50 => Some("50%"), + Self::Alert75 => Some("75%"), + Self::Alert90 => Some("90%"), Self::OverBudget => Some("over budget"), Self::Unconfigured => Some("no budget"), Self::Normal => None, } } + pub(crate) const fn summary_suffix(self) -> Option<&'static str> { + match self { + Self::Alert50 => Some("Budget alert 50%"), + Self::Alert75 => Some("Budget alert 75%"), + Self::Alert90 => Some("Budget alert 90%"), + Self::OverBudget => Some("Budget exceeded"), + Self::Unconfigured | Self::Normal => None, + } + } + pub(crate) fn style(self) -> Style { let base = Style::default().fg(match self { Self::Unconfigured => Color::DarkGray, Self::Normal => Color::DarkGray, - Self::Warning => Color::Yellow, + Self::Alert50 => Color::Cyan, + Self::Alert75 => Color::Yellow, + Self::Alert90 => Color::LightRed, Self::OverBudget => Color::Red, }); - if self.is_warning() { + if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) { base.add_modifier(Modifier::BOLD) } else { base @@ -187,8 +201,12 @@ pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState { BudgetState::Unconfigured } else if used / budget >= 1.0 { BudgetState::OverBudget - } else if used / budget >= WARNING_THRESHOLD { - BudgetState::Warning + } else if used / budget >= ALERT_THRESHOLD_90 { + BudgetState::Alert90 + } else if used / budget >= ALERT_THRESHOLD_75 { + BudgetState::Alert75 + } else if used / budget >= ALERT_THRESHOLD_50 { + BudgetState::Alert50 } else { BudgetState::Normal } @@ -200,13 +218,13 @@ pub(crate) fn gradient_color(ratio: f64) -> Color { const RED: (u8, u8, u8) = (239, 68, 68); let clamped = ratio.clamp(0.0, 1.0); - if clamped <= WARNING_THRESHOLD { - interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD) + if clamped <= ALERT_THRESHOLD_75 { + interpolate_rgb(GREEN, YELLOW, clamped / ALERT_THRESHOLD_75) } else { interpolate_rgb( YELLOW, RED, - (clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD), + (clamped - ALERT_THRESHOLD_75) / (1.0 - ALERT_THRESHOLD_75), ) } } @@ -249,16 +267,29 @@ mod tests { use super::{gradient_color, BudgetState, TokenMeter}; #[test] - fn warning_state_starts_at_eighty_percent() { - let meter = TokenMeter::tokens("Token Budget", 80, 100); - - assert_eq!(meter.state(), BudgetState::Warning); + fn budget_state_uses_alert_threshold_ladder() { + assert_eq!( + TokenMeter::tokens("Token Budget", 50, 100).state(), + BudgetState::Alert50 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 75, 100).state(), + BudgetState::Alert75 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 90, 100).state(), + BudgetState::Alert90 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 100, 100).state(), + BudgetState::OverBudget + ); } #[test] fn gradient_runs_from_green_to_yellow_to_red() { assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94)); - assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8)); + assert_eq!(gradient_color(0.75), Color::Rgb(234, 179, 8)); assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68)); }