diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index cfb07403..8243f109 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -122,6 +122,52 @@ pub struct SessionMetadata { /// Optional mode label (agent/plan/etc.) #[serde(default)] pub mode: Option, + /// Accumulated cost data for persisted billing and high-water mark. + #[serde(default)] + pub cost: SessionCostSnapshot, +} + +/// Cost and high-water-mark fields persisted with each session. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct SessionCostSnapshot { + /// Accumulated parent-turn session cost in USD. + #[serde(default)] + pub session_cost_usd: f64, + /// Accumulated parent-turn session cost in CNY. + #[serde(default)] + pub session_cost_cny: f64, + /// Accumulated sub-agent/background LLM cost in USD. + #[serde(default)] + pub subagent_cost_usd: f64, + /// Accumulated sub-agent/background LLM cost in CNY. + #[serde(default)] + pub subagent_cost_cny: f64, + /// Max-ever displayed session+subagent cost in USD (preserves #244 + /// monotonic guarantee across session restarts). + #[serde(default)] + pub displayed_cost_high_water_usd: f64, + /// Max-ever displayed session+subagent cost in CNY. + #[serde(default)] + pub displayed_cost_high_water_cny: f64, +} + +impl SessionCostSnapshot { + /// Session + subagent cost in USD. + pub fn total_usd(&self) -> f64 { + self.session_cost_usd + self.subagent_cost_usd + } + + /// Session + subagent cost in CNY. + pub fn total_cny(&self) -> f64 { + self.session_cost_cny + self.subagent_cost_cny + } +} + +impl SessionMetadata { + /// Copy cost fields from another metadata (used when forking a session). + pub fn copy_cost_from(&mut self, other: &SessionMetadata) { + self.cost = other.cost; + } } /// A saved session containing full conversation history diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fec8c63e..16202c5f 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -26,6 +26,11 @@ use super::history::{HistoryCell, ToolCell, ToolStatus}; use super::subagent_routing::active_fanout_counts; use super::ui::truncate_line_to_width; +/// Tolerance for floating-point cost comparison in the sidebar breakdown. +/// Must be large enough that accumulated f64 error across hundreds of turns +/// does not prematurely hide the session+agents breakdown. +const COST_EQ_TOLERANCE: f64 = 1e-6; + pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { if area.width < 24 || area.height < 8 { // Paint a styled block over the area so stale cells from a previous @@ -674,7 +679,11 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { let total_cost = app.displayed_session_cost_for_currency(app.cost_currency); let session_cost = app.session_cost_for_currency(app.cost_currency); let agent_cost = app.subagent_cost_for_currency(app.cost_currency); - lines.push(Line::from(Span::styled( + let real_total = session_cost + agent_cost; + // Only show the additive breakdown when it matches the displayed + // total; when the high-water mark is in effect (post-reconciliation), + // the breakdown would not sum to the displayed value (#244). + let cost_line = if (displayed_total - real_total).abs() < COST_EQ_TOLERANCE { format!( "cost: {} (session {} + agents {})", app.format_cost_amount(total_cost), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f6bb984c..bb3179cf 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6256,8 +6256,17 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.session.subagent_cost = 0.0; app.session.subagent_cost_cny = 0.0; app.session.subagent_cost_event_seqs.clear(); - app.session.displayed_cost_high_water = 0.0; - app.session.displayed_cost_high_water_cny = 0.0; + // Restore the high-water marks from persisted metadata so the + // monotonic cost guarantee (#244) survives session restarts. + // Take the max with the current totals — old sessions without + // persisted high-water fields deserialise to 0.0 and fall back to + // the restored total with no regression. + let total_restored_usd = session.metadata.cost.total_usd(); + let total_restored_cny = session.metadata.cost.total_cny(); + app.session.displayed_cost_high_water = + session.metadata.cost.displayed_cost_high_water_usd.max(total_restored_usd); + app.session.displayed_cost_high_water_cny = + session.metadata.cost.displayed_cost_high_water_cny.max(total_restored_cny); app.session.last_prompt_tokens = None; app.session.last_completion_tokens = None; app.session.last_prompt_cache_hit_tokens = None;