From 1d6df7fab64001494d444e1264ac3c2dba921541 Mon Sep 17 00:00:00 2001 From: sikongyue <3155157671@qq.com> Date: Sun, 2 Aug 2026 01:47:51 +0800 Subject: [PATCH] feat(ai): Show context window usage as absolute tokens (e.g. 112k / 1M) Display absolute token counts alongside percentages in AI Agent context window usage UI, so users can judge when to compact or hand off based on actual token counts rather than relative percentages. - Add context_window_tokens and context_window_limit fields to ConversationUsageInfo - Add context_window_tokens() method in conversation.rs, computed by summing segment token counts - Pass context window token data through terminal/view.rs - Update conversation_usage_view.rs display format: - With limit: "112k / 1M (41%)" - Without limit: "112k (41%)" - Fallback: "41%" (when no segment data available) - Update agent input footer tooltip: "41% context remaining (112k / 1M)" - Fix struct initialization in gql_convert.rs and test files Closes issue: Show context window usage as absolute tokens --- app/src/ai/agent/conversation.rs | 12 ++++ .../agent_view/agent_input_footer/mod.rs | 46 ++++++++++++- .../usage/conversation_usage_view.rs | 67 +++++++++++++++++-- .../usage/conversation_usage_view_tests.rs | 2 + app/src/terminal/view.rs | 4 ++ app/src/workspaces/gql_convert.rs | 10 +++ 6 files changed, 133 insertions(+), 8 deletions(-) diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index f98a9bf41a9..3d97ca14328 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -764,6 +764,18 @@ impl AIConversation { &self.conversation_usage_metadata.context_window_segments } + /// Total absolute token count currently used in the context window, + /// computed by summing all segment token counts. Returns None when + /// the server did not emit segments. + pub fn context_window_tokens(&self) -> Option { + let segments = self.context_window_segments(); + if segments.is_empty() { + None + } else { + Some(segments.iter().map(|s| s.token_count).sum()) + } + } + /// Total credits spent in the conversation, including both LLM inference /// and platform credits. pub fn credits_spent(&self) -> f32 { diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index e11c7c4b515..5dddb8854e9 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -2059,17 +2059,57 @@ impl AgentInputFooter { let icon = icon_for_context_window_usage(usage); let remaining_pct = ((1.0 - usage) * 100.0).round() as i32; + // Build tooltip with absolute token count when available. + // Examples: "41% context remaining (112k / 1M)", "41% context remaining" + let token_info = conversation.context_window_tokens().map(|tokens| { + let limit = AIExecutionProfilesModel::as_ref(ctx) + .active_profile() + .context_window_limit_for_request(ctx); + let tokens_str = if tokens < 1_000 { + tokens.to_string() + } else if tokens < 1_000_000 { + let k = tokens as f64 / 1_000.0; + if k.fract() == 0.0 { + format!("{}k", k as u64) + } else { + format!("{:.1}k", k) + } + } else { + let m = tokens as f64 / 1_000_000.0; + if m.fract() == 0.0 { + format!("{}M", m as u64) + } else { + format!("{:.1}M", m) + } + }; + if let Some(limit) = limit { + let limit_str = if limit < 1_000_000 { + format!("{}k", limit / 1_000) + } else { + format!("{}M", limit / 1_000_000) + }; + format!("{tokens_str} / {limit_str}") + } else { + tokens_str + } + }); + + let context_remaining_text = if let Some(token_info) = token_info { + format!("{remaining_pct}% context remaining ({token_info})") + } else { + format!("{remaining_pct}% context remaining") + }; + let expiry = conversation.latest_exchange().and_then(|exchange| { let output = exchange.output_status.output()?; output.get().model_info.as_ref()?.prompt_cache_expires_at }); let is_cache_expired = FeatureFlag::PromptCacheExpiryWarning.is_enabled() && expiry.is_some_and(|expiry| expiry <= Local::now()); - let context_remaining_tooltip = format!("{remaining_pct}% context remaining"); let tooltip = if is_cache_expired { - format!("{context_remaining_tooltip} · prompt cache expired") + format!("{context_remaining_text} · prompt cache expired") } else { - context_remaining_tooltip + context_remaining_text }; self.prompt_cache_expired = is_cache_expired; diff --git a/app/src/ai/blocklist/usage/conversation_usage_view.rs b/app/src/ai/blocklist/usage/conversation_usage_view.rs index d208f1d89ed..a4cfe041c7c 100644 --- a/app/src/ai/blocklist/usage/conversation_usage_view.rs +++ b/app/src/ai/blocklist/usage/conversation_usage_view.rs @@ -49,6 +49,10 @@ pub struct ConversationUsageInfo { pub tool_calls: i32, pub models: Vec, pub context_window_usage: f32, + /// Absolute token count currently used in the context window. + pub context_window_tokens: Option, + /// The configured context window limit (e.g. 1M tokens). + pub context_window_limit: Option, /// Per-segment breakdown of the context window. Scaled so the segments /// sum to `context_window_usage`. Empty when the server did not emit it. pub context_window_segments: Vec, @@ -466,19 +470,51 @@ impl ConversationUsageView { } labels.push(render_label_text("Context window used", appearance)); + + // Build display string: absolute tokens as primary, percentage as secondary. + // Examples: "112k / 1M (11%)", "112k (41%)" let context_usage_pct = self.usage_info.context_window_usage * 100.; - let context_usage_str = if context_window_breakdown_enabled && self.context_window_expanded - { - format!("{context_usage_pct:.2}%") + let pct_str = if context_usage_pct.fract() == 0.0 { + format!("{}%", context_usage_pct as u32) } else { - format!("{}%", context_usage_pct.round()) + format!("{:.1}%", context_usage_pct) }; + + let context_display_str = + if let (Some(tokens), Some(limit)) = + (self.usage_info.context_window_tokens, self.usage_info.context_window_limit) + { + // Show absolute tokens with limit: "112k / 1M (41%)" + let limit_str = format_token_count(limit); + let tokens_str = format_token_count(tokens); + if context_window_breakdown_enabled && self.context_window_expanded { + format!("{tokens_str} / {limit_str} ({context_usage_pct:.2}%)") + } else { + format!("{tokens_str} / {limit_str} ({pct_str})") + } + } else if let Some(tokens) = self.usage_info.context_window_tokens { + // Show absolute tokens without limit: "112k (41%)" + let tokens_str = format_token_count(tokens); + if context_window_breakdown_enabled && self.context_window_expanded { + format!("{tokens_str} ({context_usage_pct:.2}%)") + } else { + format!("{tokens_str} ({pct_str})") + } + } else { + // Fallback to percentage only when no segment data is available. + if context_window_breakdown_enabled && self.context_window_expanded { + format!("{context_usage_pct:.2}%") + } else { + pct_str + } + }; + let mut context_window_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_spacing(4.) .with_child( - Text::new(context_usage_str, appearance.ui_font_family(), font_size) + Text::new(context_display_str, appearance.ui_font_family(), font_size) .with_color(text_color) .finish(), ) @@ -1182,6 +1218,27 @@ const CONTEXT_WINDOW_SEGMENT_PERCENT_DECIMAL_PLACES: usize = 2; /// Maximum width of the context-window "Other" tooltip before wrapping. const CONTEXT_WINDOW_OTHER_TOOLTIP_MAX_WIDTH: f32 = 280.; +/// Formats a token count into a human-readable string (e.g. 112k, 1.2M). +fn format_token_count(tokens: u32) -> String { + if tokens < 1_000 { + tokens.to_string() + } else if tokens < 1_000_000 { + let k = tokens as f64 / 1_000.0; + if k.fract() == 0.0 { + format!("{}k", k as u64) + } else { + format!("{:.1}k", k) + } + } else { + let m = tokens as f64 / 1_000_000.0; + if m.fract() == 0.0 { + format!("{}M", m as u64) + } else { + format!("{:.1}M", m) + } + } +} + #[cfg(test)] #[path = "conversation_usage_view_tests.rs"] mod tests; diff --git a/app/src/ai/blocklist/usage/conversation_usage_view_tests.rs b/app/src/ai/blocklist/usage/conversation_usage_view_tests.rs index 63c1ce244bb..31a6cda72a8 100644 --- a/app/src/ai/blocklist/usage/conversation_usage_view_tests.rs +++ b/app/src/ai/blocklist/usage/conversation_usage_view_tests.rs @@ -39,6 +39,8 @@ fn placeholder_usage_info() -> ConversationUsageInfo { tool_calls: 0, models: Vec::new(), context_window_usage: 0.0, + context_window_tokens: None, + context_window_limit: None, context_window_segments: Vec::new(), files_changed: 0, lines_added: 0, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 57ade30dd35..fa0e7f24794 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -6895,6 +6895,10 @@ impl TerminalView { tool_calls: tool_usage.total_tool_calls(), models: conversation.token_usage().to_vec(), context_window_usage: conversation.context_window_usage(), + context_window_tokens: conversation.context_window_tokens(), + context_window_limit: AIExecutionProfilesModel::as_ref(ctx) + .active_profile() + .context_window_limit_for_request(ctx), context_window_segments: conversation.context_window_segments().to_vec(), files_changed: tool_usage.apply_file_diff_stats.files_changed, lines_added: tool_usage.apply_file_diff_stats.lines_added, diff --git a/app/src/workspaces/gql_convert.rs b/app/src/workspaces/gql_convert.rs index 5ad5500f450..3aa28d0b46b 100644 --- a/app/src/workspaces/gql_convert.rs +++ b/app/src/workspaces/gql_convert.rs @@ -320,6 +320,14 @@ impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo { context_window_segments, .. } = (&gql.usage_metadata).into(); + + // Compute absolute context window tokens from segments when available. + let context_window_tokens = if context_window_segments.is_empty() { + None + } else { + Some(context_window_segments.iter().map(|s| s.token_count).sum()) + }; + ConversationUsageInfo { credits_spent, platform_credits_spent, @@ -327,6 +335,8 @@ impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo { tool_calls: tool.total_tool_calls(), models, context_window_usage, + context_window_tokens, + context_window_limit: None, context_window_segments, files_changed: tool.apply_file_diff_stats.files_changed, lines_added: tool.apply_file_diff_stats.lines_added,