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,