mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat: add ecc2 budget alert thresholds
This commit is contained in:
parent
08f61f667d
commit
95c33d3c04
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user