diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index eac8f9e36..9b2b06e0a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -324,8 +324,7 @@ pub struct Config { /// Ordered list of status line item identifiers for the TUI. /// - /// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and - /// `current-dir`. + /// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`. pub tui_status_line: Option>, /// Ordered list of terminal title item identifiers for the TUI. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap index 94d9cd450..20aca1e33 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -13,9 +13,9 @@ expression: "render_lines(&view, 72)" [x] git-branch Current Git branch (omitted when unavaila… [ ] model-with-reasoning Current model name with reasoning level [ ] project-root Project root directory (omitted when unav… - [ ] context-usage Visual meter of context window usage (omi… + [ ] context-remaining Percentage of context window remaining (o… + [ ] context-used Percentage of context window used (omitte… [ ] five-hour-limit Remaining usage on 5-hour usage limit (om… - [ ] weekly-limit Remaining usage on weekly usage limit (om… gpt-5-codex · ~/codex-rs · jif/statusline-preview Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index ed4f76c1d..7265d61ad 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -12,7 +12,7 @@ //! - Model information (name, reasoning level) //! - Directory paths (current dir, project root) //! - Git information (branch name) -//! - Context usage (meter, window size) +//! - Context usage (remaining %, used %, window size) //! - Usage limits (5-hour, weekly) //! - Session info (thread title, ID, tokens used) //! - Application version @@ -22,6 +22,7 @@ use ratatui::layout::Rect; use ratatui::text::Line; use std::collections::BTreeMap; use std::collections::HashSet; +use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use strum_macros::EnumString; @@ -62,15 +63,14 @@ pub(crate) enum StatusLineItem { /// Current git branch name (if in a repository). GitBranch, - /// Visual meter of context window usage. + /// Percentage of context window remaining. + ContextRemaining, + + /// Percentage of context window used. /// - /// Also accepts legacy `context-remaining` and `context-used` config values. - #[strum( - to_string = "context-usage", - serialize = "context-remaining", - serialize = "context-used" - )] - ContextUsage, + /// Also accepts the legacy `context-usage` config value. + #[strum(to_string = "context-used", serialize = "context-usage")] + ContextUsed, /// Remaining usage on the 5-hour rate limit. FiveHourLimit, @@ -112,8 +112,11 @@ impl StatusLineItem { StatusLineItem::CurrentDir => "Current working directory", StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)", StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", - StatusLineItem::ContextUsage => { - "Visual meter of context window usage (omitted when unknown)" + StatusLineItem::ContextRemaining => { + "Percentage of context window remaining (omitted when unknown)" + } + StatusLineItem::ContextUsed => { + "Percentage of context window used (omitted when unknown)" } StatusLineItem::FiveHourLimit => { "Remaining usage on 5-hour usage limit (omitted when unavailable)" @@ -137,25 +140,6 @@ impl StatusLineItem { } } -const SELECTABLE_STATUS_LINE_ITEMS: &[StatusLineItem] = &[ - StatusLineItem::ModelName, - StatusLineItem::ModelWithReasoning, - StatusLineItem::CurrentDir, - StatusLineItem::ProjectRoot, - StatusLineItem::GitBranch, - StatusLineItem::ContextUsage, - StatusLineItem::FiveHourLimit, - StatusLineItem::WeeklyLimit, - StatusLineItem::CodexVersion, - StatusLineItem::ContextWindowSize, - StatusLineItem::UsedTokens, - StatusLineItem::TotalInputTokens, - StatusLineItem::TotalOutputTokens, - StatusLineItem::SessionId, - StatusLineItem::FastMode, - StatusLineItem::ThreadTitle, -]; - /// Runtime values used to preview the current status-line selection. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct StatusLinePreviewData { @@ -232,7 +216,7 @@ impl StatusLineSetupView { } } - for item in SELECTABLE_STATUS_LINE_ITEMS.iter().cloned() { + for item in StatusLineItem::iter() { let item_id = item.to_string(); if used_ids.contains(&item_id) { continue; @@ -317,19 +301,27 @@ mod tests { use crate::app_event::AppEvent; #[test] - fn context_usage_is_canonical_and_accepts_legacy_ids() { - assert_eq!(StatusLineItem::ContextUsage.to_string(), "context-usage"); - assert_eq!( - "context-usage".parse::(), - Ok(StatusLineItem::ContextUsage) - ); - assert_eq!( - "context-remaining".parse::(), - Ok(StatusLineItem::ContextUsage) - ); + fn context_used_accepts_context_usage_legacy_id() { + assert_eq!(StatusLineItem::ContextUsed.to_string(), "context-used"); assert_eq!( "context-used".parse::(), - Ok(StatusLineItem::ContextUsage) + Ok(StatusLineItem::ContextUsed) + ); + assert_eq!( + "context-usage".parse::(), + Ok(StatusLineItem::ContextUsed) + ); + } + + #[test] + fn context_remaining_is_separate_selectable_id() { + assert_eq!( + "context-remaining".parse::(), + Ok(StatusLineItem::ContextRemaining) + ); + assert_eq!( + StatusLineItem::ContextRemaining.to_string(), + "context-remaining" ); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap index c71a57060..2ac794d2f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_fast_footer.snap @@ -6,4 +6,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.4 xhigh fast · Context [ ] · /tmp/project " +" gpt-5.4 xhigh fast · Context 0% used · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 078d31617..a744711c2 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -450,9 +450,12 @@ impl ChatWidget { Some(format!("{} used", format_tokens_compact(total))) } } - StatusLineItem::ContextUsage => self + StatusLineItem::ContextRemaining => self + .status_line_context_remaining_percent() + .map(|remaining| format!("Context {remaining}% left")), + StatusLineItem::ContextUsed => self .status_line_context_used_percent() - .map(format_context_used_meter), + .map(|used| format!("Context {used}% used")), StatusLineItem::FiveHourLimit => { let window = self .rate_limit_snapshots_by_limit_id @@ -661,61 +664,3 @@ where } (items, invalid) } - -fn format_context_used_meter(used_percent: i64) -> String { - const METER_WIDTH: usize = 5; - const EIGHTHS_PER_CELL: i64 = 8; - const PARTIAL_BLOCKS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; - - let used_percent = used_percent.clamp(0, 100); - let total_eighths = (used_percent * METER_WIDTH as i64 * EIGHTHS_PER_CELL + 50) / 100; - let filled_cells = (total_eighths / EIGHTHS_PER_CELL) as usize; - let partial_eighths = (total_eighths % EIGHTHS_PER_CELL) as usize; - - let mut meter = String::with_capacity(METER_WIDTH); - meter.push_str(&"█".repeat(filled_cells)); - meter.push_str(PARTIAL_BLOCKS[partial_eighths]); - - let occupied_cells = filled_cells + usize::from(partial_eighths > 0); - meter.push_str(&" ".repeat(METER_WIDTH.saturating_sub(occupied_cells))); - - format!("Context [{meter}]") -} - -#[cfg(test)] -mod tests { - use super::format_context_used_meter; - use pretty_assertions::assert_eq; - - #[test] - fn context_meter_uses_five_cells_with_partial_blocks() { - assert_eq!( - format_context_used_meter(/*used_percent*/ 100), - "Context [█████]" - ); - assert_eq!( - format_context_used_meter(/*used_percent*/ 50), - "Context [██▌ ]" - ); - assert_eq!( - format_context_used_meter(/*used_percent*/ 10), - "Context [▌ ]" - ); - assert_eq!( - format_context_used_meter(/*used_percent*/ 0), - "Context [ ]" - ); - } - - #[test] - fn context_meter_clamps_out_of_range_values() { - assert_eq!( - format_context_used_meter(/*used_percent*/ 125), - "Context [█████]" - ); - assert_eq!( - format_context_used_meter(/*used_percent*/ -1), - "Context [ ]" - ); - } -} diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 8ac21627c..1c43d2e91 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -867,32 +867,50 @@ async fn status_line_invalid_items_warn_once() { } #[tokio::test] -async fn status_line_legacy_context_used_renders_context_meter() { +async fn status_line_context_used_renders_labeled_percent() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.config.tui_status_line = Some(vec!["context-used".to_string()]); chat.refresh_status_line(); - assert_eq!(status_line_text(&chat), Some("Context [ ]".to_string())); + assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string())); assert!( drain_insert_history(&mut rx).is_empty(), - "legacy context-used should remain a valid status line item" + "context-used should remain a valid status line item" ); } #[tokio::test] -async fn status_line_legacy_context_remaining_renders_context_meter() { +async fn status_line_context_remaining_renders_labeled_percent() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.config.tui_status_line = Some(vec!["context-remaining".to_string()]); chat.refresh_status_line(); - assert_eq!(status_line_text(&chat), Some("Context [ ]".to_string())); + assert_eq!( + status_line_text(&chat), + Some("Context 100% left".to_string()) + ); assert!( drain_insert_history(&mut rx).is_empty(), - "legacy context-remaining should remain a valid status line item" + "context-remaining should remain a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_legacy_context_usage_renders_context_used_percent() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["context-usage".to_string()]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "legacy context-usage should remain a valid status line item" ); } @@ -995,7 +1013,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), - "context-usage".to_string(), + "context-used".to_string(), "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -1008,7 +1026,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( assert_eq!( status_line_text(&chat), - Some(format!("gpt-5.4 xhigh fast · Context [ ] · {test_cwd}")) + Some(format!("gpt-5.4 xhigh fast · Context 0% used · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -1017,7 +1035,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( assert_eq!( status_line_text(&chat), Some(format!( - "gpt-5.3-codex xhigh · Context [ ] · {test_cwd}" + "gpt-5.3-codex xhigh · Context 0% used · {test_cwd}" )) ); } @@ -1141,7 +1159,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { chat.config.cwd = test_project_path().abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), - "context-usage".to_string(), + "context-used".to_string(), "current-dir".to_string(), ]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));