Expose context usage in statusline state (#61409)

## What changed

Add `thread.usage.context` with optional `context_window_size`, `used_tokens`, and `used_percentage` fields. Use the latest token report for context usage and the built-in footer's window size and percentage calculations. Omit unavailable fields, including the percentage when the context window is unknown.

## Testing

Extend statusline state tests to cover missing usage, unknown context windows, and footer-consistent percentages, with context usage distinct from cumulative token totals.

GitOrigin-RevId: 699b65f6c047e76fcc7d1ba729eebd19a19e4bcd
This commit is contained in:
Joe Savona
2026-11-14 00:07:11 +00:00
committed by copyberry
parent 91ae3d10bf
commit e87cd55ed9
3 changed files with 74 additions and 4 deletions
@@ -6,10 +6,21 @@ use serde::Serialize;
#[derive(Serialize)]
pub(super) struct Usage {
context: Context,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<Counts>,
}
#[derive(Serialize)]
struct Context {
#[serde(skip_serializing_if = "Option::is_none")]
context_window_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
used_tokens: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
used_percentage: Option<i64>,
}
#[derive(Serialize)]
struct Counts {
total_input_tokens: i64,
@@ -52,7 +63,17 @@ impl Counts {
impl From<&ChatWidget> for Usage {
fn from(chat: &ChatWidget) -> Self {
let context_window_size = chat.status_line_context_window_size();
Self {
context: Context {
context_window_size,
used_tokens: chat
.token_info
.as_ref()
.map(|info| info.last_token_usage.tokens_in_context_window()),
used_percentage: context_window_size
.and_then(|_| chat.status_line_context_used_percent()),
},
total: chat
.token_info
.as_ref()
@@ -388,6 +388,7 @@ async fn app_server_model_verification_renders_warning() {
async fn context_indicator_shows_used_tokens_when_window_unknown() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await;
chat.thread_id = Some(ThreadId::new());
chat.config.model_context_window = None;
let auto_compact_limit = 200_000;
chat.config.model_auto_compact_token_limit = Some(auto_compact_limit);
@@ -399,18 +400,39 @@ async fn context_indicator_shows_used_tokens_when_window_unknown() {
..TokenUsage::default()
};
let token_info = TokenUsageInfo {
total_token_usage: token_usage.clone(),
last_token_usage: token_usage,
total_token_usage: token_usage,
last_token_usage: TokenUsage {
total_tokens: 40_000,
..TokenUsage::default()
},
model_context_window: None,
};
handle_token_count(&mut chat, Some(token_info));
handle_token_count(&mut chat, Some(token_info.clone()));
assert_eq!(chat.bottom_pane.context_window_percent(), None);
assert_eq!(
chat.bottom_pane.context_window_used_tokens(),
Some(total_tokens)
);
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(
state["thread"]["usage"]["context"],
json!({"used_tokens": 40_000})
);
handle_token_count(
&mut chat,
Some(TokenUsageInfo {
model_context_window: Some(68_000),
..token_info
}),
);
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(
state["thread"]["usage"]["context"],
json!({"context_window_size": 68_000, "used_tokens": 40_000, "used_percentage": 50})
);
}
#[tokio::test]
@@ -34,7 +34,7 @@ async fn statusline_state_projects_the_displayed_settings() {
"config": {"statusline_items": ["run-state"]},
"thread": {
"workspace": {"cwd": expected_cwd},
"usage": {},
"usage": {"context": {}},
"id": id, "name": "Saved carts", "agent": {"state": "ready"},
"model": {"id": "gpt-5.5", "display_name": "Test model", "provider": "openai",
"reasoning_level": "none", "service_tier": "acme-priority", "service_tier_display_name": "Turbo"},
@@ -92,3 +92,30 @@ async fn statusline_totals_use_the_reported_token_breakdown() {
assert_eq!(state["thread"]["usage"].get("total").is_some(), has_total);
}
}
#[tokio::test]
async fn statusline_context_uses_the_footer_window_and_percentage() {
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.handle_thread_session(configured_thread_session(ThreadId::new()));
chat.config.model_context_window = Some(80_000);
helpers::handle_token_count(&mut chat, /*info*/ None);
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(
state["thread"]["usage"]["context"],
json!({"context_window_size": 80_000})
);
// The last response occupies half the usable window after the footer's fixed overhead.
let mut info = helpers::make_token_info(
/*total_tokens*/ 56_000, /*context_window*/ 100_000,
);
info.total_token_usage.total_tokens = 220_000;
helpers::handle_token_count(&mut chat, Some(info));
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(
state["thread"]["usage"]["context"],
json!({
"context_window_size": 100_000, "used_tokens": 56_000, "used_percentage": 50
})
);
}