feat: add ecc2 budget alert thresholds

This commit is contained in:
Affaan Mustafa
2026-04-09 06:31:54 -07:00
parent 08f61f667d
commit 95c33d3c04
2 changed files with 165 additions and 39 deletions

View File

@@ -102,6 +102,7 @@ pub struct Dashboard {
selected_search_match: usize, selected_search_match: usize,
session_table_state: TableState, session_table_state: TableState,
last_cost_metrics_signature: Option<(u64, u128)>, last_cost_metrics_signature: Option<(u64, u128)>,
last_budget_alert_state: BudgetState,
} }
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
@@ -344,6 +345,7 @@ impl Dashboard {
selected_search_match: 0, selected_search_match: 0,
session_table_state, session_table_state,
last_cost_metrics_signature: initial_cost_metrics_signature, 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.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
dashboard.sync_handoff_backlog_counts(); dashboard.sync_handoff_backlog_counts();
@@ -353,6 +355,7 @@ impl Dashboard {
dashboard.sync_selected_messages(); dashboard.sync_selected_messages();
dashboard.sync_selected_lineage(); dashboard.sync_selected_lineage();
dashboard.refresh_logs(); dashboard.refresh_logs();
dashboard.last_budget_alert_state = dashboard.aggregate_usage().overall_state;
dashboard dashboard
} }
@@ -989,16 +992,20 @@ impl Dashboard {
" y Toggle selected-session timeline view".to_string(), " y Toggle selected-session timeline view".to_string(),
" E Cycle timeline event filter".to_string(), " E Cycle timeline event filter".to_string(),
" v Toggle selected worktree diff in output pane".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(), " 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(), " 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(), " A Toggle search or timeline scope between selected session and all sessions"
" o Toggle search agent filter between all agents and selected agent type".to_string(), .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 selected ready worktree into base and clean it up".to_string(),
" M Merge all ready inactive worktrees and clean them up".to_string(), " M Merge all ready inactive worktrees and clean them up".to_string(),
" l Cycle pane layout and persist it".to_string(), " l Cycle pane layout and persist it".to_string(),
" T Toggle theme 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(), " p Toggle daemon auto-dispatch policy and persist config".to_string(),
" w Toggle daemon auto-merge for ready inactive worktrees".to_string(), " w Toggle daemon auto-merge for ready inactive worktrees".to_string(),
" ,/. Decrease/increase auto-dispatch limit per lead".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) { pub fn begin_pane_command_mode(&mut self) {
self.pane_command_mode = true; self.pane_command_mode = true;
self.set_operator_note( self.set_operator_note(
"pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize" "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize".to_string(),
.to_string(),
); );
} }
@@ -1165,7 +1171,6 @@ impl Dashboard {
true true
} }
pub fn collapse_selected_pane(&mut self) { pub fn collapse_selected_pane(&mut self) {
if self.selected_pane == Pane::Sessions { if self.selected_pane == Pane::Sessions {
self.set_operator_note("cannot collapse sessions pane".to_string()); self.set_operator_note("cannot collapse sessions pane".to_string());
@@ -1648,6 +1653,7 @@ impl Dashboard {
self.sync_selected_messages(); self.sync_selected_messages();
self.sync_selected_lineage(); self.sync_selected_lineage();
self.refresh_logs(); self.refresh_logs();
self.sync_budget_alerts();
} }
pub fn toggle_output_mode(&mut self) { pub fn toggle_output_mode(&mut self) {
@@ -4012,8 +4018,7 @@ impl Dashboard {
)); ));
lines.push(format!( lines.push(format!(
"Tools {} | Files {}", "Tools {} | Files {}",
metrics.tool_calls, metrics.tool_calls, metrics.files_changed,
metrics.files_changed,
)); ));
lines.push(format!( lines.push(format!(
"Cost ${:.4} | Duration {}s", "Cost ${:.4} | Duration {}s",
@@ -4080,15 +4085,56 @@ impl Dashboard {
) )
}; };
match aggregate.overall_state { if let Some(summary_suffix) = aggregate.overall_state.summary_suffix() {
BudgetState::Warning => text.push_str(" | Budget warning"), text.push_str(" | ");
BudgetState::OverBudget => text.push_str(" | Budget exceeded"), text.push_str(summary_suffix);
_ => {}
} }
(text, aggregate.overall_state.style()) (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<String> { fn attention_queue_items(&self, limit: usize) -> Vec<String> {
let mut items = Vec::new(); let mut items = Vec::new();
let suppress_inbox_attention = self let suppress_inbox_attention = self
@@ -7033,10 +7079,60 @@ diff --git a/src/next.rs b/src/next.rs
assert_eq!( assert_eq!(
dashboard.aggregate_cost_summary_text(), 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] #[test]
fn new_session_task_uses_selected_session_context() { fn new_session_task_uses_selected_session_context() {
let dashboard = test_dashboard( 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_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid);
assert!( assert!(dashboard
dashboard .operator_note
.operator_note .as_deref()
.as_deref() .is_some_and(|note| note.contains("pane layout set to grid | saved to ")));
.is_some_and(|note| note.contains("pane layout set to grid | saved to "))
);
} }
#[test] #[test]
@@ -8961,6 +9055,7 @@ diff --git a/src/next.rs b/src/next.rs
selected_search_match: 0, selected_search_match: 0,
session_table_state, session_table_state,
last_cost_metrics_signature: None, last_cost_metrics_signature: None,
last_budget_alert_state: BudgetState::Normal,
} }
} }

View File

@@ -4,39 +4,53 @@ use ratatui::{
widgets::{Gauge, Paragraph, Widget}, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum BudgetState { pub(crate) enum BudgetState {
Unconfigured, Unconfigured,
Normal, Normal,
Warning, Alert50,
Alert75,
Alert90,
OverBudget, OverBudget,
} }
impl BudgetState { impl BudgetState {
pub(crate) const fn is_warning(self) -> bool {
matches!(self, Self::Warning | Self::OverBudget)
}
fn badge(self) -> Option<&'static str> { fn badge(self) -> Option<&'static str> {
match self { match self {
Self::Warning => Some("warning"), Self::Alert50 => Some("50%"),
Self::Alert75 => Some("75%"),
Self::Alert90 => Some("90%"),
Self::OverBudget => Some("over budget"), Self::OverBudget => Some("over budget"),
Self::Unconfigured => Some("no budget"), Self::Unconfigured => Some("no budget"),
Self::Normal => None, 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 { pub(crate) fn style(self) -> Style {
let base = Style::default().fg(match self { let base = Style::default().fg(match self {
Self::Unconfigured => Color::DarkGray, Self::Unconfigured => Color::DarkGray,
Self::Normal => 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, Self::OverBudget => Color::Red,
}); });
if self.is_warning() { if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
base.add_modifier(Modifier::BOLD) base.add_modifier(Modifier::BOLD)
} else { } else {
base base
@@ -187,8 +201,12 @@ pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
BudgetState::Unconfigured BudgetState::Unconfigured
} else if used / budget >= 1.0 { } else if used / budget >= 1.0 {
BudgetState::OverBudget BudgetState::OverBudget
} else if used / budget >= WARNING_THRESHOLD { } else if used / budget >= ALERT_THRESHOLD_90 {
BudgetState::Warning BudgetState::Alert90
} else if used / budget >= ALERT_THRESHOLD_75 {
BudgetState::Alert75
} else if used / budget >= ALERT_THRESHOLD_50 {
BudgetState::Alert50
} else { } else {
BudgetState::Normal BudgetState::Normal
} }
@@ -200,13 +218,13 @@ pub(crate) fn gradient_color(ratio: f64) -> Color {
const RED: (u8, u8, u8) = (239, 68, 68); const RED: (u8, u8, u8) = (239, 68, 68);
let clamped = ratio.clamp(0.0, 1.0); let clamped = ratio.clamp(0.0, 1.0);
if clamped <= WARNING_THRESHOLD { if clamped <= ALERT_THRESHOLD_75 {
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD) interpolate_rgb(GREEN, YELLOW, clamped / ALERT_THRESHOLD_75)
} else { } else {
interpolate_rgb( interpolate_rgb(
YELLOW, YELLOW,
RED, 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}; use super::{gradient_color, BudgetState, TokenMeter};
#[test] #[test]
fn warning_state_starts_at_eighty_percent() { fn budget_state_uses_alert_threshold_ladder() {
let meter = TokenMeter::tokens("Token Budget", 80, 100); assert_eq!(
TokenMeter::tokens("Token Budget", 50, 100).state(),
assert_eq!(meter.state(), BudgetState::Warning); 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] #[test]
fn gradient_runs_from_green_to_yellow_to_red() { 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.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)); assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68));
} }