From a1eeacdfaea1afaf3196669e67b2041c6c8db6ef Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 18:21:05 +0800 Subject: [PATCH 1/9] fix(tui): persist session cost across save/restore cycles Session cost (USD/CNY) was reset to zero on every session restore because only total_tokens was persisted. Carry session/subagent cost through SessionMetadata so resumed sessions show the correct billing. Also pass the full session+subagent displayed cost (in the user's chosen currency) to the header data instead of just session_cost. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/rename.rs | 4 ++++ crates/tui/src/commands/session.rs | 6 +++++- crates/tui/src/main.rs | 6 +++++- crates/tui/src/session_manager.rs | 24 ++++++++++++++++++++++++ crates/tui/src/tui/session_picker.rs | 4 ++++ crates/tui/src/tui/ui.rs | 26 +++++++++++++++++++------- crates/tui/src/tui/ui/tests.rs | 4 ++++ 7 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/rename.rs index 705ec02f..d539a40e 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/rename.rs @@ -58,6 +58,10 @@ fn rename_with_manager( u64::from(app.session.total_tokens), app.system_prompt.as_ref(), ); + session.metadata.session_cost_usd = app.session.session_cost; + session.metadata.session_cost_cny = app.session.session_cost_cny; + session.metadata.subagent_cost_usd = app.session.subagent_cost; + session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; session.metadata.title = new_title.to_string(); match manager.save_session(&session) { diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 20f68459..692503e9 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -20,7 +20,7 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { }; let messages = app.api_messages.clone(); - let session = create_saved_session_with_mode( + let mut session = create_saved_session_with_mode( &messages, &app.model, &app.workspace, @@ -28,6 +28,10 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { app.system_prompt.as_ref(), Some(app.mode.label()), ); + session.metadata.session_cost_usd = app.session.session_cost; + session.metadata.session_cost_cny = app.session.session_cost_cny; + session.metadata.subagent_cost_usd = app.session.subagent_cost; + session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; let sessions_dir = save_path .parent() diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1557a25a..c5b2582c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2790,13 +2790,17 @@ fn fork_session(session_id: Option, last: bool, workspace: &Path) -> Res .system_prompt .as_ref() .map(|text| SystemPrompt::Text(text.clone())); - let forked = create_saved_session( + let mut forked = create_saved_session( &saved.messages, &saved.metadata.model, &saved.metadata.workspace, saved.metadata.total_tokens, system_prompt.as_ref(), ); + forked.metadata.session_cost_usd = saved.metadata.session_cost_usd; + forked.metadata.session_cost_cny = saved.metadata.session_cost_cny; + forked.metadata.subagent_cost_usd = saved.metadata.subagent_cost_usd; + forked.metadata.subagent_cost_cny = saved.metadata.subagent_cost_cny; manager.save_session(&forked)?; let source_title = saved.metadata.title.trim(); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 4c82ab1d..0b76f80a 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -96,6 +96,18 @@ pub struct SessionMetadata { /// Optional mode label (agent/plan/etc.) #[serde(default)] pub mode: Option, + /// Accumulated parent-turn session cost in USD (for persisted billing). + #[serde(default)] + pub session_cost_usd: f64, + /// Accumulated parent-turn session cost in CNY (for persisted billing). + #[serde(default)] + pub session_cost_cny: f64, + /// Accumulated sub-agent/background LLM cost in USD (for persisted billing). + #[serde(default)] + pub subagent_cost_usd: f64, + /// Accumulated sub-agent/background LLM cost in CNY (for persisted billing). + #[serde(default)] + pub subagent_cost_cny: f64, } /// A saved session containing full conversation history @@ -580,6 +592,10 @@ pub fn create_saved_session_with_mode( model: model.to_string(), workspace: workspace.to_path_buf(), mode: mode.map(str::to_string), + session_cost_usd: 0.0, + session_cost_cny: 0.0, + subagent_cost_usd: 0.0, + subagent_cost_cny: 0.0, }, messages: capped_messages, system_prompt: merge_truncation_note( @@ -847,6 +863,10 @@ mod tests { model: "deepseek-v4-flash".to_string(), workspace: workspace.to_path_buf(), mode: None, + session_cost_usd: 0.0, + session_cost_cny: 0.0, + subagent_cost_usd: 0.0, + subagent_cost_cny: 0.0, }, system_prompt: None, context_references: Vec::new(), @@ -873,6 +893,10 @@ mod tests { model: "deepseek-v4-pro".to_string(), workspace: workspace.to_path_buf(), mode: Some("yolo".to_string()), + session_cost_usd: 0.0, + session_cost_cny: 0.0, + subagent_cost_usd: 0.0, + subagent_cost_cny: 0.0, }, system_prompt: None, context_references: Vec::new(), diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 3e43dea0..61b257b1 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -611,6 +611,10 @@ mod tests { model: "deepseek-v4-pro".to_string(), workspace: std::path::PathBuf::from("/tmp"), mode: Some("agent".to_string()), + session_cost_usd: 0.0, + session_cost_cny: 0.0, + subagent_cost_usd: 0.0, + subagent_cost_cny: 0.0, } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c7f5b208..e7748847 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3001,6 +3001,10 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { app.system_prompt.as_ref(), ); updated.metadata.mode = Some(app.mode.as_setting().to_string()); + updated.metadata.session_cost_usd = app.session.session_cost; + updated.metadata.session_cost_cny = app.session.session_cost_cny; + updated.metadata.subagent_cost_usd = app.session.subagent_cost; + updated.metadata.subagent_cost_cny = app.session.subagent_cost_cny; updated.context_references = app.session_context_references.clone(); updated } else { @@ -3012,6 +3016,10 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { app.system_prompt.as_ref(), Some(app.mode.as_setting()), ); + session.metadata.session_cost_usd = app.session.session_cost; + session.metadata.session_cost_cny = app.session.session_cost_cny; + session.metadata.subagent_cost_usd = app.session.subagent_cost; + session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; session.context_references = app.session_context_references.clone(); session } @@ -5365,7 +5373,7 @@ fn render(f: &mut Frame, app: &mut App) { .with_usage( app.session.total_conversation_tokens, sanitized_context_window, - app.session.session_cost, + app.displayed_session_cost_for_currency(app.cost_currency), sanitized_prompt_tokens, ) .with_reasoning_effort(Some(&effort_label)) @@ -6061,13 +6069,17 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.workspace.clone_from(&session.metadata.workspace); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; - app.session.session_cost = 0.0; - app.session.session_cost_cny = 0.0; - app.session.subagent_cost = 0.0; - app.session.subagent_cost_cny = 0.0; + app.session.session_cost = session.metadata.session_cost_usd; + app.session.session_cost_cny = session.metadata.session_cost_cny; + app.session.subagent_cost = session.metadata.subagent_cost_usd; + app.session.subagent_cost_cny = session.metadata.subagent_cost_cny; app.session.subagent_cost_event_seqs.clear(); - app.session.displayed_cost_high_water = 0.0; - app.session.displayed_cost_high_water_cny = 0.0; + // Set high-water marks to the restored totals so the footer never + // reverses when reconciliation events fire on a resumed session. + let total_restored_usd = app.session.session_cost + app.session.subagent_cost; + let total_restored_cny = app.session.session_cost_cny + app.session.subagent_cost_cny; + app.session.displayed_cost_high_water = total_restored_usd; + app.session.displayed_cost_high_water_cny = total_restored_cny; app.session.last_prompt_tokens = None; app.session.last_completion_tokens = None; app.session.last_prompt_cache_hit_tokens = None; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 1c0ca72e..8a307adb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -695,6 +695,10 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("/tmp/resume-recovery"), mode: Some("yolo".to_string()), + session_cost_usd: 0.0, + session_cost_cny: 0.0, + subagent_cost_usd: 0.0, + subagent_cost_cny: 0.0, }, messages, system_prompt: None, From bd7c08ce3e5df9b914c1656e2824f7f20d4e3e02 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 18:35:09 +0800 Subject: [PATCH 2/9] fix(tui): persist session cost across save/restore cycles Session cost (USD/CNY) was reset to zero on every session restore because only total_tokens was persisted. Carry session/subagent cost through SessionMetadata so resumed sessions show the correct billing. Also extract repeated cost-sync lines into App::sync_cost_to_metadata. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/rename.rs | 5 +---- crates/tui/src/commands/session.rs | 5 +---- crates/tui/src/tui/app.rs | 9 +++++++++ crates/tui/src/tui/ui.rs | 12 +++--------- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/rename.rs index d539a40e..e551cf61 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/rename.rs @@ -58,10 +58,7 @@ fn rename_with_manager( u64::from(app.session.total_tokens), app.system_prompt.as_ref(), ); - session.metadata.session_cost_usd = app.session.session_cost; - session.metadata.session_cost_cny = app.session.session_cost_cny; - session.metadata.subagent_cost_usd = app.session.subagent_cost; - session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; + app.sync_cost_to_metadata(&mut session.metadata); session.metadata.title = new_title.to_string(); match manager.save_session(&session) { diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 692503e9..67acdbbf 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -28,10 +28,7 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { app.system_prompt.as_ref(), Some(app.mode.label()), ); - session.metadata.session_cost_usd = app.session.session_cost; - session.metadata.session_cost_cny = app.session.session_cost_cny; - session.metadata.subagent_cost_usd = app.session.subagent_cost; - session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; + app.sync_cost_to_metadata(&mut session.metadata); let sessions_dir = save_path .parent() diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5332623b..362925f9 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1648,6 +1648,15 @@ impl App { self.refresh_displayed_cost_high_water(); } + /// Copy current session/subagent cost accumulators into session metadata + /// for persistence. + pub fn sync_cost_to_metadata(&self, metadata: &mut crate::session_manager::SessionMetadata) { + metadata.session_cost_usd = self.session.session_cost; + metadata.session_cost_cny = self.session.session_cost_cny; + metadata.subagent_cost_usd = self.session.subagent_cost; + metadata.subagent_cost_cny = self.session.subagent_cost_cny; + } + /// Recompute the displayed cost high-water mark. Called any time a cost /// counter is mutated; never decreases. pub fn refresh_displayed_cost_high_water(&mut self) { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e7748847..e2503be7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3001,10 +3001,7 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { app.system_prompt.as_ref(), ); updated.metadata.mode = Some(app.mode.as_setting().to_string()); - updated.metadata.session_cost_usd = app.session.session_cost; - updated.metadata.session_cost_cny = app.session.session_cost_cny; - updated.metadata.subagent_cost_usd = app.session.subagent_cost; - updated.metadata.subagent_cost_cny = app.session.subagent_cost_cny; + app.sync_cost_to_metadata(&mut updated.metadata); updated.context_references = app.session_context_references.clone(); updated } else { @@ -3016,10 +3013,7 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { app.system_prompt.as_ref(), Some(app.mode.as_setting()), ); - session.metadata.session_cost_usd = app.session.session_cost; - session.metadata.session_cost_cny = app.session.session_cost_cny; - session.metadata.subagent_cost_usd = app.session.subagent_cost; - session.metadata.subagent_cost_cny = app.session.subagent_cost_cny; + app.sync_cost_to_metadata(&mut session.metadata); session.context_references = app.session_context_references.clone(); session } @@ -5373,7 +5367,7 @@ fn render(f: &mut Frame, app: &mut App) { .with_usage( app.session.total_conversation_tokens, sanitized_context_window, - app.displayed_session_cost_for_currency(app.cost_currency), + app.session.session_cost, sanitized_prompt_tokens, ) .with_reasoning_effort(Some(&effort_label)) From 5accda22d672f13d12993266af815ead338f5c29 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 18:48:23 +0800 Subject: [PATCH 3/9] fix(tui): show real-time cost total in sidebar and /cost commands Sidebar cost line used displayed_cost_high_water for the total but real values for the session/agent breakdown, so total did not equal session + agents. Use real-time sums everywhere for internal consistency. /cost and /tokens commands now include sub-agent costs in the displayed total, matching the footer chip. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/debug.rs | 9 +++++++-- crates/tui/src/tui/sidebar.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 615165c2..7af8f064 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -73,7 +73,10 @@ pub fn tokens(app: &mut App) -> CommandResult { .replace("{total}", &app.session.total_tokens.to_string()) .replace( "{cost}", - &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)), + &app.format_cost_amount_precise( + app.session_cost_for_currency(app.cost_currency) + + app.subagent_cost_for_currency(app.cost_currency), + ), ) .replace("{api_messages}", &message_count.to_string()) .replace("{chat_messages}", &chat_count.to_string()) @@ -83,9 +86,11 @@ pub fn tokens(app: &mut App) -> CommandResult { /// Show session cost breakdown pub fn cost(app: &mut App) -> CommandResult { + let total = app.session_cost_for_currency(app.cost_currency) + + app.subagent_cost_for_currency(app.cost_currency); let report = tr(app.ui_locale, MessageId::CmdCostReport).replace( "{cost}", - &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)), + &app.format_cost_amount_precise(total), ); CommandResult::message(report) } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index daf28436..0fd68124 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -671,9 +671,9 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { ))); // ── Session cost ───────────────────────────────────────────── - 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); + let total_cost = session_cost + agent_cost; lines.push(Line::from(Span::styled( format!( "cost: {} (session {} + agents {})", From b6b1599b8ccd99f8aad539230c99c16e61c4cf66 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 19:07:48 +0800 Subject: [PATCH 4/9] fix(tui): preserve high-water mark in sidebar, add copy_cost_from helper Sidebar cost total now uses displayed_session_cost_for_currency so the high-water mark protects against cost reversal during API reconciliation (#244). The session+agents breakdown is only shown when it matches the displayed total; otherwise a single total line is rendered. Add SessionMetadata::copy_cost_from to eliminate the last remaining manual four-field cost copy (fork-session path). Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/main.rs | 5 +---- crates/tui/src/session_manager.rs | 10 ++++++++++ crates/tui/src/tui/sidebar.rs | 17 +++++++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index c5b2582c..18f784c0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2797,10 +2797,7 @@ fn fork_session(session_id: Option, last: bool, workspace: &Path) -> Res saved.metadata.total_tokens, system_prompt.as_ref(), ); - forked.metadata.session_cost_usd = saved.metadata.session_cost_usd; - forked.metadata.session_cost_cny = saved.metadata.session_cost_cny; - forked.metadata.subagent_cost_usd = saved.metadata.subagent_cost_usd; - forked.metadata.subagent_cost_cny = saved.metadata.subagent_cost_cny; + forked.metadata.copy_cost_from(&saved.metadata); manager.save_session(&forked)?; let source_title = saved.metadata.title.trim(); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 0b76f80a..0149cfd2 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -110,6 +110,16 @@ pub struct SessionMetadata { pub subagent_cost_cny: f64, } +impl SessionMetadata { + /// Copy cost fields from another metadata (used when forking a session). + pub fn copy_cost_from(&mut self, other: &SessionMetadata) { + self.session_cost_usd = other.session_cost_usd; + self.session_cost_cny = other.session_cost_cny; + self.subagent_cost_usd = other.subagent_cost_usd; + self.subagent_cost_cny = other.subagent_cost_cny; + } +} + /// A saved session containing full conversation history #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedSession { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 0fd68124..f09e359e 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -671,16 +671,25 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { ))); // ── Session cost ───────────────────────────────────────────── + let displayed_total = 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); - let total_cost = session_cost + agent_cost; - 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() < 1e-9 { format!( "cost: {} (session {} + agents {})", - app.format_cost_amount(total_cost), + app.format_cost_amount(displayed_total), app.format_cost_amount(session_cost), app.format_cost_amount(agent_cost) - ), + ) + } else { + format!("cost: {}", app.format_cost_amount(displayed_total)) + }; + lines.push(Line::from(Span::styled( + cost_line, Style::default().fg(palette::TEXT_MUTED), ))); From 53076e23f2265b1c9305918d54b45fc03f10207d Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 19:22:47 +0800 Subject: [PATCH 5/9] fix(tui): persist displayed cost high-water mark across sessions The high-water mark prevents the footer cost from reversing during API reconciliation (#244), but it was not persisted in SessionMetadata so session restarts could still show a lower cost than before the restart. Store displayed_cost_high_water_usd/cny in session metadata and restore via max(saved_high_water, current_total) so old sessions fall back to the restored total with no regression. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/session_manager.rs | 15 +++++++++++++++ crates/tui/src/tui/app.rs | 2 ++ crates/tui/src/tui/session_picker.rs | 2 ++ crates/tui/src/tui/ui.rs | 13 +++++++++---- crates/tui/src/tui/ui/tests.rs | 2 ++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 0149cfd2..f3212d24 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -108,6 +108,13 @@ pub struct SessionMetadata { /// Accumulated sub-agent/background LLM cost in CNY (for persisted billing). #[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 SessionMetadata { @@ -117,6 +124,8 @@ impl SessionMetadata { self.session_cost_cny = other.session_cost_cny; self.subagent_cost_usd = other.subagent_cost_usd; self.subagent_cost_cny = other.subagent_cost_cny; + self.displayed_cost_high_water_usd = other.displayed_cost_high_water_usd; + self.displayed_cost_high_water_cny = other.displayed_cost_high_water_cny; } } @@ -606,6 +615,8 @@ pub fn create_saved_session_with_mode( session_cost_cny: 0.0, subagent_cost_usd: 0.0, subagent_cost_cny: 0.0, + displayed_cost_high_water_usd: 0.0, + displayed_cost_high_water_cny: 0.0, }, messages: capped_messages, system_prompt: merge_truncation_note( @@ -877,6 +888,8 @@ mod tests { session_cost_cny: 0.0, subagent_cost_usd: 0.0, subagent_cost_cny: 0.0, + displayed_cost_high_water_usd: 0.0, + displayed_cost_high_water_cny: 0.0, }, system_prompt: None, context_references: Vec::new(), @@ -907,6 +920,8 @@ mod tests { session_cost_cny: 0.0, subagent_cost_usd: 0.0, subagent_cost_cny: 0.0, + displayed_cost_high_water_usd: 0.0, + displayed_cost_high_water_cny: 0.0, }, system_prompt: None, context_references: Vec::new(), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 362925f9..71977d46 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1655,6 +1655,8 @@ impl App { metadata.session_cost_cny = self.session.session_cost_cny; metadata.subagent_cost_usd = self.session.subagent_cost; metadata.subagent_cost_cny = self.session.subagent_cost_cny; + metadata.displayed_cost_high_water_usd = self.session.displayed_cost_high_water; + metadata.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny; } /// Recompute the displayed cost high-water mark. Called any time a cost diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 61b257b1..84a4200c 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -615,6 +615,8 @@ mod tests { session_cost_cny: 0.0, subagent_cost_usd: 0.0, subagent_cost_cny: 0.0, + displayed_cost_high_water_usd: 0.0, + displayed_cost_high_water_cny: 0.0, } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e2503be7..f91ff57e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6068,12 +6068,17 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.session.subagent_cost = session.metadata.subagent_cost_usd; app.session.subagent_cost_cny = session.metadata.subagent_cost_cny; app.session.subagent_cost_event_seqs.clear(); - // Set high-water marks to the restored totals so the footer never - // reverses when reconciliation events fire on a resumed session. + // 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 = app.session.session_cost + app.session.subagent_cost; let total_restored_cny = app.session.session_cost_cny + app.session.subagent_cost_cny; - app.session.displayed_cost_high_water = total_restored_usd; - app.session.displayed_cost_high_water_cny = total_restored_cny; + app.session.displayed_cost_high_water = + session.metadata.displayed_cost_high_water_usd.max(total_restored_usd); + app.session.displayed_cost_high_water_cny = + session.metadata.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; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8a307adb..c6aa424c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -699,6 +699,8 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { session_cost_cny: 0.0, subagent_cost_usd: 0.0, subagent_cost_cny: 0.0, + displayed_cost_high_water_usd: 0.0, + displayed_cost_high_water_cny: 0.0, }, messages, system_prompt: None, From d24abd97ac39c2b7880591cbd358f15caae665f5 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 19:31:30 +0800 Subject: [PATCH 6/9] fix(tui): use displayed cost in /cost and /tokens commands Switch /cost and /tokens from session_cost+subagent_cost (raw sum) to displayed_session_cost_for_currency so the numbers match the footer and sidebar UI, which apply the high-water mark for consistency. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/commands/debug.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index 7af8f064..49f1a5e8 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -74,8 +74,7 @@ pub fn tokens(app: &mut App) -> CommandResult { .replace( "{cost}", &app.format_cost_amount_precise( - app.session_cost_for_currency(app.cost_currency) - + app.subagent_cost_for_currency(app.cost_currency), + app.displayed_session_cost_for_currency(app.cost_currency), ), ) .replace("{api_messages}", &message_count.to_string()) @@ -86,8 +85,7 @@ pub fn tokens(app: &mut App) -> CommandResult { /// Show session cost breakdown pub fn cost(app: &mut App) -> CommandResult { - let total = app.session_cost_for_currency(app.cost_currency) - + app.subagent_cost_for_currency(app.cost_currency); + let total = app.displayed_session_cost_for_currency(app.cost_currency); let report = tr(app.ui_locale, MessageId::CmdCostReport).replace( "{cost}", &app.format_cost_amount_precise(total), From 3668a0774c1b602e100a9fed886d646e7c6c1a9e Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 19:47:57 +0800 Subject: [PATCH 7/9] fix(tui): group cost fields into SessionCostSnapshot struct Extract the 6 cost-related fields from SessionMetadata into a dedicated SessionCostSnapshot struct. This makes copy_cost_from a simple struct copy and keeps the metadata definition focused. Also bump the sidebar floating-point epsilon from 1e-9 to 1e-6 so accumulated floating-point noise across many turns doesn't prematurely hide the session+agents cost breakdown. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/session_manager.rs | 44 ++++++++++------------------ crates/tui/src/tui/app.rs | 13 ++++---- crates/tui/src/tui/session_picker.rs | 11 +++---- crates/tui/src/tui/sidebar.rs | 4 ++- crates/tui/src/tui/ui.rs | 12 ++++---- crates/tui/src/tui/ui/tests.rs | 7 +---- 6 files changed, 37 insertions(+), 54 deletions(-) diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index f3212d24..45953272 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -96,16 +96,24 @@ pub struct SessionMetadata { /// Optional mode label (agent/plan/etc.) #[serde(default)] pub mode: Option, - /// Accumulated parent-turn session cost in USD (for persisted billing). + /// 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 (for persisted billing). + /// Accumulated parent-turn session cost in CNY. #[serde(default)] pub session_cost_cny: f64, - /// Accumulated sub-agent/background LLM cost in USD (for persisted billing). + /// Accumulated sub-agent/background LLM cost in USD. #[serde(default)] pub subagent_cost_usd: f64, - /// Accumulated sub-agent/background LLM cost in CNY (for persisted billing). + /// 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 @@ -120,12 +128,7 @@ pub struct SessionMetadata { impl SessionMetadata { /// Copy cost fields from another metadata (used when forking a session). pub fn copy_cost_from(&mut self, other: &SessionMetadata) { - self.session_cost_usd = other.session_cost_usd; - self.session_cost_cny = other.session_cost_cny; - self.subagent_cost_usd = other.subagent_cost_usd; - self.subagent_cost_cny = other.subagent_cost_cny; - self.displayed_cost_high_water_usd = other.displayed_cost_high_water_usd; - self.displayed_cost_high_water_cny = other.displayed_cost_high_water_cny; + self.cost = other.cost; } } @@ -611,12 +614,7 @@ pub fn create_saved_session_with_mode( model: model.to_string(), workspace: workspace.to_path_buf(), mode: mode.map(str::to_string), - session_cost_usd: 0.0, - session_cost_cny: 0.0, - subagent_cost_usd: 0.0, - subagent_cost_cny: 0.0, - displayed_cost_high_water_usd: 0.0, - displayed_cost_high_water_cny: 0.0, + cost: SessionCostSnapshot::default(), }, messages: capped_messages, system_prompt: merge_truncation_note( @@ -884,12 +882,7 @@ mod tests { model: "deepseek-v4-flash".to_string(), workspace: workspace.to_path_buf(), mode: None, - session_cost_usd: 0.0, - session_cost_cny: 0.0, - subagent_cost_usd: 0.0, - subagent_cost_cny: 0.0, - displayed_cost_high_water_usd: 0.0, - displayed_cost_high_water_cny: 0.0, + cost: SessionCostSnapshot::default(), }, system_prompt: None, context_references: Vec::new(), @@ -916,12 +909,7 @@ mod tests { model: "deepseek-v4-pro".to_string(), workspace: workspace.to_path_buf(), mode: Some("yolo".to_string()), - session_cost_usd: 0.0, - session_cost_cny: 0.0, - subagent_cost_usd: 0.0, - subagent_cost_cny: 0.0, - displayed_cost_high_water_usd: 0.0, - displayed_cost_high_water_cny: 0.0, + cost: SessionCostSnapshot::default(), }, system_prompt: None, context_references: Vec::new(), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 71977d46..869726a2 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1651,12 +1651,13 @@ impl App { /// Copy current session/subagent cost accumulators into session metadata /// for persistence. pub fn sync_cost_to_metadata(&self, metadata: &mut crate::session_manager::SessionMetadata) { - metadata.session_cost_usd = self.session.session_cost; - metadata.session_cost_cny = self.session.session_cost_cny; - metadata.subagent_cost_usd = self.session.subagent_cost; - metadata.subagent_cost_cny = self.session.subagent_cost_cny; - metadata.displayed_cost_high_water_usd = self.session.displayed_cost_high_water; - metadata.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny; + metadata.cost.session_cost_usd = self.session.session_cost; + metadata.cost.session_cost_cny = self.session.session_cost_cny; + metadata.cost.subagent_cost_usd = self.session.subagent_cost; + metadata.cost.subagent_cost_cny = self.session.subagent_cost_cny; + metadata.cost.displayed_cost_high_water_usd = self.session.displayed_cost_high_water; + metadata.cost.displayed_cost_high_water_cny = + self.session.displayed_cost_high_water_cny; } /// Recompute the displayed cost high-water mark. Called any time a cost diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 84a4200c..fc75796d 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -15,7 +15,9 @@ use ratatui::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; -use crate::session_manager::{SavedSession, SessionManager, SessionMetadata}; +use crate::session_manager::{ + SavedSession, SessionCostSnapshot, SessionManager, SessionMetadata, +}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; fn modal_block(title: &str) -> Block<'static> { @@ -611,12 +613,7 @@ mod tests { model: "deepseek-v4-pro".to_string(), workspace: std::path::PathBuf::from("/tmp"), mode: Some("agent".to_string()), - session_cost_usd: 0.0, - session_cost_cny: 0.0, - subagent_cost_usd: 0.0, - subagent_cost_cny: 0.0, - displayed_cost_high_water_usd: 0.0, - displayed_cost_high_water_cny: 0.0, + cost: SessionCostSnapshot::default(), } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index f09e359e..ff34f757 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -678,7 +678,9 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { // 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() < 1e-9 { + // Epsilon must be large enough that floating-point accumulation + // across many turns doesn't prematurely hide the breakdown. + let cost_line = if (displayed_total - real_total).abs() < 1e-6 { format!( "cost: {} (session {} + agents {})", app.format_cost_amount(displayed_total), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f91ff57e..cecc459f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6063,10 +6063,10 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.workspace.clone_from(&session.metadata.workspace); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; - app.session.session_cost = session.metadata.session_cost_usd; - app.session.session_cost_cny = session.metadata.session_cost_cny; - app.session.subagent_cost = session.metadata.subagent_cost_usd; - app.session.subagent_cost_cny = session.metadata.subagent_cost_cny; + app.session.session_cost = session.metadata.cost.session_cost_usd; + app.session.session_cost_cny = session.metadata.cost.session_cost_cny; + app.session.subagent_cost = session.metadata.cost.subagent_cost_usd; + app.session.subagent_cost_cny = session.metadata.cost.subagent_cost_cny; app.session.subagent_cost_event_seqs.clear(); // Restore the high-water marks from persisted metadata so the // monotonic cost guarantee (#244) survives session restarts. @@ -6076,9 +6076,9 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { let total_restored_usd = app.session.session_cost + app.session.subagent_cost; let total_restored_cny = app.session.session_cost_cny + app.session.subagent_cost_cny; app.session.displayed_cost_high_water = - session.metadata.displayed_cost_high_water_usd.max(total_restored_usd); + session.metadata.cost.displayed_cost_high_water_usd.max(total_restored_usd); app.session.displayed_cost_high_water_cny = - session.metadata.displayed_cost_high_water_cny.max(total_restored_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; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c6aa424c..6b41f740 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -695,12 +695,7 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { model: "deepseek-v4-pro".to_string(), workspace: PathBuf::from("/tmp/resume-recovery"), mode: Some("yolo".to_string()), - session_cost_usd: 0.0, - session_cost_cny: 0.0, - subagent_cost_usd: 0.0, - subagent_cost_cny: 0.0, - displayed_cost_high_water_usd: 0.0, - displayed_cost_high_water_cny: 0.0, + cost: crate::session_manager::SessionCostSnapshot::default(), }, messages, system_prompt: None, From d29fbcb24b0521b10a2dc42c636ecba67c5cfe1c Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 19:55:15 +0800 Subject: [PATCH 8/9] fix(tui): replace magic number with named COST_EQ_TOLERANCE constant Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/sidebar.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index ff34f757..92142037 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -678,9 +678,10 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { // 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). - // Epsilon must be large enough that floating-point accumulation + // Tolerance must be large enough that floating-point accumulation // across many turns doesn't prematurely hide the breakdown. - let cost_line = if (displayed_total - real_total).abs() < 1e-6 { + const COST_EQ_TOLERANCE: f64 = 1e-6; + let cost_line = if (displayed_total - real_total).abs() < COST_EQ_TOLERANCE { format!( "cost: {} (session {} + agents {})", app.format_cost_amount(displayed_total), From d1187e2b8fc73fe92d28d2a7b327e80be420aed2 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 20:24:11 +0800 Subject: [PATCH 9/9] fix(tui): move tolerance to module-level const, add total helpers Promote COST_EQ_TOLERANCE from a function-local const to a module-level constant in sidebar.rs. Add SessionCostSnapshot::total_usd() and total_cny() helpers that encapsulate session+subagent cost summation, used during session restore. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/session_manager.rs | 12 ++++++++++++ crates/tui/src/tui/sidebar.rs | 8 +++++--- crates/tui/src/tui/ui.rs | 4 ++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 45953272..a71cb5ba 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -125,6 +125,18 @@ pub struct SessionCostSnapshot { 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) { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 92142037..48c3e34f 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 @@ -678,9 +683,6 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { // 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). - // Tolerance must be large enough that floating-point accumulation - // across many turns doesn't prematurely hide the breakdown. - const COST_EQ_TOLERANCE: f64 = 1e-6; let cost_line = if (displayed_total - real_total).abs() < COST_EQ_TOLERANCE { format!( "cost: {} (session {} + agents {})", diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index cecc459f..bb73ae37 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6073,8 +6073,8 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { // 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 = app.session.session_cost + app.session.subagent_cost; - let total_restored_cny = app.session.session_cost_cny + app.session.subagent_cost_cny; + 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 =