Show context window usage while tasks run (#4536)

## Summary
- show the remaining context window percentage in `/status` alongside
existing token usage details
- replace the composer shortcut prompt with the context window
percentage (or an unavailable message) while a task is running
- update TUI snapshots to reflect the new context window line

## Testing
- cargo test -p codex-tui

------
https://chatgpt.com/codex/tasks/task_i_68dc6e7397ac8321909d7daff25a396c
This commit is contained in:
Ahmed Ibrahim
2025-10-01 11:03:05 -07:00
committed by GitHub
parent 751b3b50ac
commit 2f370e946d
13 changed files with 236 additions and 78 deletions

View File

@@ -29,11 +29,19 @@ use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
#[derive(Debug, Clone)]
struct StatusContextWindowData {
percent_remaining: u8,
tokens_in_context: u64,
window: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct StatusTokenUsageData {
total: u64,
input: u64,
output: u64,
context_window: Option<StatusContextWindowData>,
}
#[derive(Debug)]
@@ -52,12 +60,13 @@ struct StatusHistoryCell {
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
total_usage: &TokenUsage,
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(config, usage, session_id, rate_limits);
let card = StatusHistoryCell::new(config, total_usage, context_usage, session_id, rate_limits);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
}
@@ -65,7 +74,8 @@ pub(crate) fn new_status_output(
impl StatusHistoryCell {
fn new(
config: &Config,
usage: &TokenUsage,
total_usage: &TokenUsage,
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
) -> Self {
@@ -84,10 +94,19 @@ impl StatusHistoryCell {
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(config);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
let context_window = config.model_context_window.and_then(|window| {
context_usage.map(|usage| StatusContextWindowData {
percent_remaining: usage.percent_of_context_window_remaining(window),
tokens_in_context: usage.tokens_in_context_window(),
window,
})
});
let token_usage = StatusTokenUsageData {
total: usage.blended_total(),
input: usage.non_cached_input(),
output: usage.output_tokens,
total: total_usage.blended_total(),
input: total_usage.non_cached_input(),
output: total_usage.output_tokens,
context_window,
};
let rate_limits = compose_rate_limit_data(rate_limits);
@@ -123,6 +142,22 @@ impl StatusHistoryCell {
]
}
fn context_window_spans(&self) -> Option<Vec<Span<'static>>> {
let context = self.token_usage.context_window.as_ref()?;
let percent = context.percent_remaining;
let used_fmt = format_tokens_compact(context.tokens_in_context);
let window_fmt = format_tokens_compact(context.window);
Some(vec![
Span::from(format!("{percent}% left")),
Span::from(" (").dim(),
Span::from(used_fmt).dim(),
Span::from(" / ").dim(),
Span::from(window_fmt).dim(),
Span::from(")").dim(),
])
}
fn rate_limit_lines(
&self,
available_inner_width: usize,
@@ -235,6 +270,9 @@ impl HistoryCell for StatusHistoryCell {
push_label(&mut labels, &mut seen, "Session");
}
push_label(&mut labels, &mut seen, "Token usage");
if self.token_usage.context_window.is_some() {
push_label(&mut labels, &mut seen, "Context window");
}
self.collect_rate_limit_labels(&mut seen, &mut labels);
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
@@ -266,6 +304,10 @@ impl HistoryCell for StatusHistoryCell {
lines.push(Line::from(Vec::<Span<'static>>::new()));
lines.push(formatter.line("Token usage", self.token_usage_spans()));
if let Some(spans) = self.context_window_spans() {
lines.push(formatter.line("Context window", spans));
}
lines.extend(self.rate_limit_lines(available_inner_width, &formatter));
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);

View File

@@ -4,15 +4,16 @@ expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token usage: 1.2K total (800 input + 400 output) │
Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May)
╰───────────────────────────────────────────────────────────────────────────╯
╭───────────────────────────────────────────────────────────────────────────
│ >_ OpenAI Codex (v0.0.0)
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]]
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ Token usage: 1.2K total (800 input + 400 output) │
Context window: 100% left (1.2K / 272K)
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
╰────────────────────────────────────────────────────────────────────────────╯

View File

@@ -4,16 +4,17 @@ expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning high, summaries detailed) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: workspace-write │
│ Agents.md: <none> │
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
5h limit: [███████████████░░░░░] 72% used (resets 03:14)
Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
╰───────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────
│ >_ OpenAI Codex (v0.0.0)
│ Model: gpt-5-codex (reasoning high, summaries detailed) │
│ Directory: [[workspace]]
│ Approval: on-request │
│ Sandbox: workspace-write │
│ Agents.md: <none> │
│ Token usage: 1.9K total (1K input + 900 output) │
Context window: 100% left (2.1K / 272K)
5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
╰─────────────────────────────────────────────────────────────────────╯

View File

@@ -4,15 +4,16 @@ expression: sanitized
---
/status
╭──────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
Limits: data not available yet
╰──────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────
│ >_ OpenAI Codex (v0.0.0)
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]]
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ Token usage: 750 total (500 input + 250 output) │
Context window: 100% left (750 / 272K)
│ Limits: data not available yet │
╰─────────────────────────────────────────────────────────────────╯

View File

@@ -4,15 +4,16 @@ expression: sanitized
---
/status
╭──────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
Limits: send a message to load usage data
╰──────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────
│ >_ OpenAI Codex (v0.0.0)
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]]
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ Token usage: 750 total (500 input + 250 output) │
Context window: 100% left (750 / 272K)
│ Limits: send a message to load usage data │
╰─────────────────────────────────────────────────────────────────╯

View File

@@ -7,13 +7,14 @@ expression: sanitized
╭────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning hig
│ Model: gpt-5-codex (reasoning │
│ Directory: [[workspace]] │
│ Approval: on-request
│ Sandbox: read-only
│ Agents.md: <none>
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token usage: 1.9K total (1K input + 90
5h limit: [███████████████░░░░░] 72%
(resets 03:14)
│ Token usage: 1.9K total (1K input + │
Context window: 100% left (2.1K / 272K)
5h limit: [███████████████░░░░░]
│ (resets 03:14) │
╰────────────────────────────────────────────╯

View File

@@ -103,7 +103,7 @@ fn status_snapshot_includes_reasoning_details() {
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -144,7 +144,7 @@ fn status_snapshot_includes_monthly_limit() {
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -170,7 +170,7 @@ fn status_card_token_usage_excludes_cached_tokens() {
total_tokens: 2_100,
};
let composite = new_status_output(&config, &usage, &None, None);
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
let rendered = render_lines(&composite.display_lines(120));
assert!(
@@ -211,7 +211,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(46));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -238,7 +238,7 @@ fn status_snapshot_shows_missing_limits_message() {
total_tokens: 750,
};
let composite = new_status_output(&config, &usage, &None, None);
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -274,7 +274,7 @@ fn status_snapshot_shows_empty_limits_message() {
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -284,3 +284,41 @@ fn status_snapshot_shows_empty_limits_message() {
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[test]
fn status_context_window_uses_last_usage() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model_context_window = Some(272_000);
let total_usage = TokenUsage {
input_tokens: 12_800,
cached_input_tokens: 0,
output_tokens: 879,
reasoning_output_tokens: 0,
total_tokens: 102_000,
};
let last_usage = TokenUsage {
input_tokens: 12_800,
cached_input_tokens: 0,
output_tokens: 879,
reasoning_output_tokens: 0,
total_tokens: 13_679,
};
let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None);
let rendered_lines = render_lines(&composite.display_lines(80));
let context_line = rendered_lines
.into_iter()
.find(|line| line.contains("Context window"))
.expect("context line");
assert!(
context_line.contains("13.7K / 272K"),
"expected context line to reflect last usage tokens, got: {context_line}"
);
assert!(
!context_line.contains("102K"),
"context line should not use total aggregated tokens, got: {context_line}"
);
}