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,
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<String> {
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,
}
}

View File

@ -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));
}