From 6cde1c74f30dfec9954bd2006a6c70709f46b8ca Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Mon, 25 May 2026 22:54:30 +0800 Subject: [PATCH 1/2] feat: session token breakdown in footer and /status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add accumulated session token tracking with input / cache-hit / output breakdown. Rebased from PR #1666 onto post-rebrand main (v0.8.45). Changes: - SessionState: new total_input_tokens, total_cache_hit_tokens, total_cache_miss_tokens, total_output_tokens fields - Turn outcome handler: accumulate per-turn token breakdown - StatusItem::Tokens: new footer chip, enabled by default - Footer chip: "12K in · 8.1K cch · 2.5K out" format - /status: expanded with session input/cache/output rows - /clear and /load: reset accumulated breakdown --- crates/tui/src/commands/core.rs | 4 ++++ crates/tui/src/commands/session.rs | 5 +++++ crates/tui/src/commands/status.rs | 18 ++++++++++++++++++ crates/tui/src/config.rs | 7 +++++++ crates/tui/src/config_ui.rs | 3 +++ crates/tui/src/tui/app.rs | 9 +++++++++ crates/tui/src/tui/footer_ui.rs | 20 ++++++++++++++++++++ crates/tui/src/tui/ui.rs | 27 ++++++++++++++++++++++++++- 8 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 9e8fd775a..d736b8fb7 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -55,6 +55,10 @@ pub fn clear(app: &mut App) -> CommandResult { app.queued_draft = None; app.session.total_tokens = 0; app.session.total_conversation_tokens = 0; + app.session.total_input_tokens = 0; + app.session.total_cache_hit_tokens = 0; + app.session.total_cache_miss_tokens = 0; + app.session.total_output_tokens = 0; app.session.session_cost = 0.0; app.session.session_cost_cny = 0.0; app.session.subagent_cost = 0.0; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 7a107797c..d301f0736 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -174,6 +174,11 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { 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; + // Accumulated token breakdown is per-runtime-session; zero on load. + app.session.total_input_tokens = 0; + app.session.total_cache_hit_tokens = 0; + app.session.total_cache_miss_tokens = 0; + app.session.total_output_tokens = 0; app.session.session_cost = 0.0; app.session.session_cost_cny = 0.0; app.session.subagent_cost = 0.0; diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/status.rs index c721dec78..3b2f1cad6 100644 --- a/crates/tui/src/commands/status.rs +++ b/crates/tui/src/commands/status.rs @@ -64,6 +64,24 @@ fn format_status(app: &App) -> String { &token_count(app.session.last_completion_tokens), ); push_row(&mut out, "Cache hit/miss:", &cache_summary(app)); + push_row( + &mut out, + "Session input:", + &app.session.total_input_tokens.to_string(), + ); + push_row( + &mut out, + "Session cache:", + &format!( + "{} hit / {} miss", + app.session.total_cache_hit_tokens, app.session.total_cache_miss_tokens + ), + ); + push_row( + &mut out, + "Session output:", + &app.session.total_output_tokens.to_string(), + ); push_row( &mut out, "Total tokens:", diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b41712557..a04f027ab 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -683,6 +683,8 @@ pub enum StatusItem { LastToolElapsed, /// Remaining rate-limit budget (placeholder until wired). RateLimit, + /// Session token usage: input / cache-hit / output. + Tokens, } impl StatusItem { @@ -701,6 +703,7 @@ impl StatusItem { StatusItem::Agents, StatusItem::ReasoningReplay, StatusItem::Cache, + StatusItem::Tokens, ] } @@ -721,6 +724,7 @@ impl StatusItem { StatusItem::GitBranch => "git_branch", StatusItem::LastToolElapsed => "last_tool_elapsed", StatusItem::RateLimit => "rate_limit", + StatusItem::Tokens => "tokens", } } @@ -741,6 +745,7 @@ impl StatusItem { StatusItem::GitBranch => "Git branch", StatusItem::LastToolElapsed => "Last tool elapsed", StatusItem::RateLimit => "Rate-limit remaining", + StatusItem::Tokens => "Session tokens", } } @@ -762,6 +767,7 @@ impl StatusItem { StatusItem::GitBranch => "current workspace branch", StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)", StatusItem::RateLimit => "remaining requests in the budget (placeholder)", + StatusItem::Tokens => "input / cache-hit / output token totals", } } @@ -782,6 +788,7 @@ impl StatusItem { StatusItem::GitBranch, StatusItem::LastToolElapsed, StatusItem::RateLimit, + StatusItem::Tokens, ] } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 7e400496e..7f3b58013 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -278,6 +278,7 @@ pub enum StatusItemValue { GitBranch, LastToolElapsed, RateLimit, + Tokens, } pub fn parse_mode(arg: Option<&str>) -> Result { @@ -996,6 +997,7 @@ impl From for StatusItemValue { StatusItem::GitBranch => Self::GitBranch, StatusItem::LastToolElapsed => Self::LastToolElapsed, StatusItem::RateLimit => Self::RateLimit, + StatusItem::Tokens => Self::Tokens, } } } @@ -1016,6 +1018,7 @@ impl From for StatusItem { StatusItemValue::GitBranch => Self::GitBranch, StatusItemValue::LastToolElapsed => Self::LastToolElapsed, StatusItemValue::RateLimit => Self::RateLimit, + StatusItemValue::Tokens => Self::Tokens, } } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4e5e78c00..64c077b1f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1005,6 +1005,11 @@ pub struct SessionState { pub last_reasoning_replay_tokens: Option, pub total_tokens: u32, pub total_conversation_tokens: u32, + /// Accumulated token breakdown for the session. + pub total_input_tokens: u32, + pub total_cache_hit_tokens: u32, + pub total_cache_miss_tokens: u32, + pub total_output_tokens: u32, pub turn_cache_history: VecDeque, pub last_cache_inspection: Option, } @@ -1026,6 +1031,10 @@ impl Default for SessionState { last_reasoning_replay_tokens: None, total_tokens: 0, total_conversation_tokens: 0, + total_input_tokens: 0, + total_cache_hit_tokens: 0, + total_cache_miss_tokens: 0, + total_output_tokens: 0, turn_cache_history: VecDeque::new(), last_cache_inspection: None, } diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 3b0c3ebd6..9e61add2d 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -518,6 +518,7 @@ pub(crate) fn render_footer_from( S::ContextPercent => footer_context_percent_spans(app), S::GitBranch => footer_git_branch_spans(app), S::LastToolElapsed | S::RateLimit => Vec::new(), + S::Tokens => footer_session_tokens_spans(app), _ => continue, }; if chip.is_empty() { @@ -593,6 +594,25 @@ pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool { displayed_cost.is_finite() && displayed_cost > 0.0 } +/// Session token-usage chip for the footer right cluster. +/// +/// Renders the accumulated input / cache-hit / output token breakdown +/// since the current runtime session started (not persisted across +/// restarts). Returns empty when no tokens have been recorded yet. +pub(crate) fn footer_session_tokens_spans(app: &App) -> Vec> { + let session = &app.session; + if session.total_input_tokens == 0 && session.total_output_tokens == 0 { + return Vec::new(); + } + let in_str = format_token_count_compact(u64::from(session.total_input_tokens)); + let cache_str = format_token_count_compact(u64::from(session.total_cache_hit_tokens)); + let out_str = format_token_count_compact(u64::from(session.total_output_tokens)); + vec![Span::styled( + format!("{in_str} in · {cache_str} cch · {out_str} out"), + Style::default().fg(palette::TEXT_MUTED), + )] +} + /// Test-only helper retained as a parity reference for `FooterWidget`'s /// auxiliary-span composition. Production rendering is performed by the /// widget itself; the existing footer parity tests still exercise this diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 25e1fdb11..d904d3a03 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1434,6 +1434,27 @@ async fn run_event_loop( .session .total_conversation_tokens .saturating_add(turn_tokens); + app.session.total_input_tokens = app + .session + .total_input_tokens + .saturating_add(usage.input_tokens); + app.session.total_output_tokens = app + .session + .total_output_tokens + .saturating_add(usage.output_tokens); + app.session.total_cache_hit_tokens = app + .session + .total_cache_hit_tokens + .saturating_add(usage.prompt_cache_hit_tokens.unwrap_or(0)); + let cache_miss = usage.prompt_cache_miss_tokens.unwrap_or_else(|| { + usage + .input_tokens + .saturating_sub(usage.prompt_cache_hit_tokens.unwrap_or(0)) + }); + app.session.total_cache_miss_tokens = app + .session + .total_cache_miss_tokens + .saturating_add(cache_miss); app.session.last_prompt_tokens = Some(usage.input_tokens); app.session.last_completion_tokens = Some(usage.output_tokens); app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; @@ -6606,8 +6627,12 @@ fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) app.session.last_prompt_cache_hit_tokens = None; app.session.last_prompt_cache_miss_tokens = None; app.session.last_reasoning_replay_tokens = None; + // Accumulated token breakdown is per-runtime-session; reset on load. + app.session.total_input_tokens = 0; + app.session.total_cache_hit_tokens = 0; + app.session.total_cache_miss_tokens = 0; + app.session.total_output_tokens = 0; app.session.turn_cache_history.clear(); - app.current_session_id = Some(session.metadata.id.clone()); app.session_artifacts = session.artifacts.clone(); app.session_title = Some(session.metadata.title.clone()); app.workspace_context = None; From 8828bd7b26f42453bca0456919d60b94c7ccee72 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Tue, 26 May 2026 13:50:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20current=5Fsession=5Fid,=20cache=20guarding,=20DRY?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore app.current_session_id assignment accidentally dropped in apply_loaded_session during rebase (P1: breaks startup-resume and session-sync paths) - Guard cache-hit/miss accumulation behind is_some() so providers that omit cache telemetry don't inflate miss totals - Extract SessionState::reset_token_breakdown() to avoid duplicating the four-field reset in core/session/ui call sites - Hide the "cch" segment from the footer token chip when no cache data has been recorded - Show "not reported" in /status session-cache row instead of "0 hit / 0 miss" when no cache telemetry is available --- crates/tui/src/commands/core.rs | 5 +---- crates/tui/src/commands/session.rs | 5 +---- crates/tui/src/commands/status.rs | 18 ++++++++-------- crates/tui/src/tui/app.rs | 10 +++++++++ crates/tui/src/tui/footer_ui.rs | 12 ++++++----- crates/tui/src/tui/ui.rs | 33 +++++++++++++++--------------- 6 files changed, 45 insertions(+), 38 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index d736b8fb7..0a50f1d8e 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -55,10 +55,7 @@ pub fn clear(app: &mut App) -> CommandResult { app.queued_draft = None; app.session.total_tokens = 0; app.session.total_conversation_tokens = 0; - app.session.total_input_tokens = 0; - app.session.total_cache_hit_tokens = 0; - app.session.total_cache_miss_tokens = 0; - app.session.total_output_tokens = 0; + app.session.reset_token_breakdown(); app.session.session_cost = 0.0; app.session.session_cost_cny = 0.0; app.session.subagent_cost = 0.0; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index d301f0736..bc51683de 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -175,10 +175,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; // Accumulated token breakdown is per-runtime-session; zero on load. - app.session.total_input_tokens = 0; - app.session.total_cache_hit_tokens = 0; - app.session.total_cache_miss_tokens = 0; - app.session.total_output_tokens = 0; + app.session.reset_token_breakdown(); app.session.session_cost = 0.0; app.session.session_cost_cny = 0.0; app.session.subagent_cost = 0.0; diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/status.rs index 3b2f1cad6..2370a06d0 100644 --- a/crates/tui/src/commands/status.rs +++ b/crates/tui/src/commands/status.rs @@ -69,14 +69,16 @@ fn format_status(app: &App) -> String { "Session input:", &app.session.total_input_tokens.to_string(), ); - push_row( - &mut out, - "Session cache:", - &format!( - "{} hit / {} miss", - app.session.total_cache_hit_tokens, app.session.total_cache_miss_tokens - ), - ); + let session_cache = + if app.session.total_cache_hit_tokens == 0 && app.session.total_cache_miss_tokens == 0 { + "not reported".to_string() + } else { + format!( + "{} hit / {} miss", + app.session.total_cache_hit_tokens, app.session.total_cache_miss_tokens + ) + }; + push_row(&mut out, "Session cache:", &session_cache); push_row( &mut out, "Session output:", diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 64c077b1f..66a70c22b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1041,6 +1041,16 @@ impl Default for SessionState { } } +impl SessionState { + /// Reset the accumulated token breakdown fields to zero. + pub fn reset_token_breakdown(&mut self) { + self.total_input_tokens = 0; + self.total_cache_hit_tokens = 0; + self.total_cache_miss_tokens = 0; + self.total_output_tokens = 0; + } +} + /// Evidence collected during a turn for the post-turn receipt. #[derive(Debug, Clone)] pub struct ToolEvidence { diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 9e61add2d..01bda3f6d 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -605,12 +605,14 @@ pub(crate) fn footer_session_tokens_spans(app: &App) -> Vec> { return Vec::new(); } let in_str = format_token_count_compact(u64::from(session.total_input_tokens)); - let cache_str = format_token_count_compact(u64::from(session.total_cache_hit_tokens)); let out_str = format_token_count_compact(u64::from(session.total_output_tokens)); - vec![Span::styled( - format!("{in_str} in · {cache_str} cch · {out_str} out"), - Style::default().fg(palette::TEXT_MUTED), - )] + let text = if session.total_cache_hit_tokens == 0 && session.total_cache_miss_tokens == 0 { + format!("{in_str} in · {out_str} out") + } else { + let cache_str = format_token_count_compact(u64::from(session.total_cache_hit_tokens)); + format!("{in_str} in · {cache_str} cch · {out_str} out") + }; + vec![Span::styled(text, Style::default().fg(palette::TEXT_MUTED))] } /// Test-only helper retained as a parity reference for `FooterWidget`'s diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d904d3a03..d11222c7c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1442,19 +1442,20 @@ async fn run_event_loop( .session .total_output_tokens .saturating_add(usage.output_tokens); - app.session.total_cache_hit_tokens = app - .session - .total_cache_hit_tokens - .saturating_add(usage.prompt_cache_hit_tokens.unwrap_or(0)); - let cache_miss = usage.prompt_cache_miss_tokens.unwrap_or_else(|| { - usage - .input_tokens - .saturating_sub(usage.prompt_cache_hit_tokens.unwrap_or(0)) - }); - app.session.total_cache_miss_tokens = app - .session - .total_cache_miss_tokens - .saturating_add(cache_miss); + // Only accumulate cache telemetry when reported. + if let Some(hit_tokens) = usage.prompt_cache_hit_tokens { + app.session.total_cache_hit_tokens = app + .session + .total_cache_hit_tokens + .saturating_add(hit_tokens); + let cache_miss = usage + .prompt_cache_miss_tokens + .unwrap_or_else(|| usage.input_tokens.saturating_sub(hit_tokens)); + app.session.total_cache_miss_tokens = app + .session + .total_cache_miss_tokens + .saturating_add(cache_miss); + } app.session.last_prompt_tokens = Some(usage.input_tokens); app.session.last_completion_tokens = Some(usage.output_tokens); app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; @@ -6628,11 +6629,9 @@ fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) app.session.last_prompt_cache_miss_tokens = None; app.session.last_reasoning_replay_tokens = None; // Accumulated token breakdown is per-runtime-session; reset on load. - app.session.total_input_tokens = 0; - app.session.total_cache_hit_tokens = 0; - app.session.total_cache_miss_tokens = 0; - app.session.total_output_tokens = 0; + app.session.reset_token_breakdown(); app.session.turn_cache_history.clear(); + app.current_session_id = Some(session.metadata.id.clone()); app.session_artifacts = session.artifacts.clone(); app.session_title = Some(session.metadata.title.clone()); app.workspace_context = None;