diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e024fd0fab..cb8acd04fb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -28,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; @@ -103,6 +104,42 @@ struct RunningCommand { parsed_cmd: Vec, } +const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0]; + +#[derive(Default)] +struct RateLimitWarningState { + weekly_index: usize, + hourly_index: usize, +} + +impl RateLimitWarningState { + fn take_warnings(&mut self, weekly_used_percent: f64, hourly_used_percent: f64) -> Vec { + let mut warnings = Vec::new(); + + while self.weekly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && weekly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index] + { + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]; + warnings.push(format!( + "Weekly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + )); + self.weekly_index += 1; + } + + while self.hourly_index < RATE_LIMIT_WARNING_THRESHOLDS.len() + && hourly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index] + { + let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]; + warnings.push(format!( + "Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage." + )); + self.hourly_index += 1; + } + + warnings + } +} + /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, @@ -124,6 +161,8 @@ pub(crate) struct ChatWidget { session_header: SessionHeader, initial_user_message: Option, token_info: Option, + rate_limit_snapshot: Option, + rate_limit_warnings: RateLimitWarningState, // Stream lifecycle controller stream: StreamController, running_commands: HashMap, @@ -285,6 +324,21 @@ impl ChatWidget { self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } + + fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(snapshot) = snapshot { + let warnings = self + .rate_limit_warnings + .take_warnings(snapshot.weekly_used_percent, snapshot.primary_used_percent); + self.rate_limit_snapshot = Some(snapshot); + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } + } /// Finalize any active exec as failed and stop/clear running UI state. fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. @@ -699,6 +753,8 @@ impl ChatWidget { initial_images, ), token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(config), running_commands: HashMap::new(), task_complete_pending: false, @@ -756,6 +812,8 @@ impl ChatWidget { initial_images, ), token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(config), running_commands: HashMap::new(), task_complete_pending: false, @@ -929,6 +987,9 @@ impl ChatWidget { SlashCommand::Status => { self.add_status_output(); } + SlashCommand::Limits => { + self.add_limits_output(); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -1106,7 +1167,10 @@ impl ChatWidget { EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { self.on_task_complete(last_agent_message) } - EventMsg::TokenCount(ev) => self.set_token_info(ev.info), + EventMsg::TokenCount(ev) => { + self.set_token_info(ev.info); + self.on_rate_limit_snapshot(ev.rate_limits); + } EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { @@ -1282,6 +1346,15 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn add_limits_output(&mut self) { + if let Some(snapshot) = &self.rate_limit_snapshot { + self.add_to_history(history_cell::new_limits_output(snapshot)); + } else { + self.add_to_history(history_cell::new_limits_unavailable()); + } + self.request_redraw(); + } + pub(crate) fn add_status_output(&mut self) { let default_usage; let usage_ref = if let Some(ti) = &self.token_info { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap new file mode 100644 index 0000000000..fa37b2201f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + Real usage data is not available yet. +[dim] Send a message to Codex, then run /limits again.[/] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap new file mode 100644 index 0000000000..ced4466837 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]30.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]60.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap new file mode 100644 index 0000000000..defc5f213c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]0.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap new file mode 100644 index 0000000000..86c82d9247 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]20.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap new file mode 100644 index 0000000000..a1650545b6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]98.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]0.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffe3f3f707..7427bb4414 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -25,6 +25,7 @@ use codex_core::protocol::InputMessageKind; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::ReviewCodeLocation; use codex_core::protocol::ReviewFinding; use codex_core::protocol::ReviewLineRange; @@ -39,6 +40,8 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +use ratatui::style::Color; +use ratatui::style::Modifier; use std::fs::File; use std::io::BufRead; use std::io::BufReader; @@ -320,6 +323,8 @@ fn make_chatwidget_manual() -> ( session_header: SessionHeader::new(cfg.model.clone()), initial_user_message: None, token_info: None, + rate_limit_snapshot: None, + rate_limit_warnings: RateLimitWarningState::default(), stream: StreamController::new(cfg), running_commands: HashMap::new(), task_complete_pending: false, @@ -375,6 +380,158 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } +fn styled_lines_to_string(lines: &[ratatui::text::Line<'static>]) -> String { + let mut out = String::new(); + for line in lines { + for span in &line.spans { + let mut tags: Vec<&str> = Vec::new(); + if let Some(color) = span.style.fg { + let name = match color { + Color::Black => "black", + Color::Blue => "blue", + Color::Cyan => "cyan", + Color::DarkGray => "dark-gray", + Color::Gray => "gray", + Color::Green => "green", + Color::LightBlue => "light-blue", + Color::LightCyan => "light-cyan", + Color::LightGreen => "light-green", + Color::LightMagenta => "light-magenta", + Color::LightRed => "light-red", + Color::LightYellow => "light-yellow", + Color::Magenta => "magenta", + Color::Red => "red", + Color::Rgb(_, _, _) => "rgb", + Color::Indexed(_) => "indexed", + Color::Reset => "reset", + Color::Yellow => "yellow", + Color::White => "white", + }; + tags.push(name); + } + let modifiers = span.style.add_modifier; + if modifiers.contains(Modifier::BOLD) { + tags.push("bold"); + } + if modifiers.contains(Modifier::DIM) { + tags.push("dim"); + } + if modifiers.contains(Modifier::ITALIC) { + tags.push("italic"); + } + if modifiers.contains(Modifier::UNDERLINED) { + tags.push("underlined"); + } + if !tags.is_empty() { + out.push('['); + out.push_str(&tags.join("+")); + out.push(']'); + } + out.push_str(&span.content); + if !tags.is_empty() { + out.push_str("[/]"); + } + } + out.push('\n'); + } + out +} + +fn sample_rate_limit_snapshot( + primary_used_percent: f64, + weekly_used_percent: f64, + ratio_percent: f64, +) -> RateLimitSnapshotEvent { + RateLimitSnapshotEvent { + primary_used_percent, + weekly_used_percent, + primary_to_weekly_ratio_percent: ratio_percent, + primary_window_minutes: 300, + weekly_window_minutes: 10_080, + } +} + +fn capture_limits_snapshot(snapshot: Option) -> String { + let lines = match snapshot { + Some(ref snapshot) => history_cell::new_limits_output(snapshot).display_lines(80), + None => history_cell::new_limits_unavailable().display_lines(80), + }; + styled_lines_to_string(&lines) +} + +#[test] +fn limits_placeholder() { + let visual = capture_limits_snapshot(None); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_basic() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(30.0, 60.0, 40.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_hourly_remaining() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(0.0, 20.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_mixed_usage() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(20.0, 20.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn limits_snapshot_weekly_heavy() { + let visual = capture_limits_snapshot(Some(sample_rate_limit_snapshot(98.0, 0.0, 10.0))); + assert_snapshot!(visual); +} + +#[test] +fn rate_limit_warnings_emit_thresholds() { + let mut state = RateLimitWarningState::default(); + let mut warnings: Vec = Vec::new(); + + warnings.extend(state.take_warnings(10.0, 55.0)); + warnings.extend(state.take_warnings(55.0, 10.0)); + warnings.extend(state.take_warnings(10.0, 80.0)); + warnings.extend(state.take_warnings(80.0, 10.0)); + warnings.extend(state.take_warnings(10.0, 95.0)); + warnings.extend(state.take_warnings(95.0, 10.0)); + + assert_eq!( + warnings.len(), + 6, + "expected one warning per threshold per limit" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Hourly usage exceeded 50%")), + "expected hourly 50% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Weekly usage exceeded 50%")), + "expected weekly 50% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Hourly usage exceeded 90%")), + "expected hourly 90% warning" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("Weekly usage exceeded 90%")), + "expected weekly 90% warning" + ); +} + // (removed experimental resize snapshot test) #[test] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a99a4dd836..24e6b2844f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2,6 +2,9 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::append_markdown; +use crate::rate_limits_view::DEFAULT_GRID_CONFIG; +use crate::rate_limits_view::LimitsView; +use crate::rate_limits_view::build_limits_view; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; @@ -24,6 +27,7 @@ use codex_core::plan_tool::UpdatePlanArgs; use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; +use codex_core::protocol::RateLimitSnapshotEvent; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; @@ -221,6 +225,20 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct LimitsHistoryCell { + display: LimitsView, +} + +impl HistoryCell for LimitsHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines = self.display.summary_lines.clone(); + lines.extend(self.display.gauge_lines(width)); + lines.extend(self.display.legend_lines.clone()); + lines + } +} + #[derive(Debug)] pub(crate) struct TranscriptOnlyHistoryCell { lines: Vec>, @@ -1075,6 +1093,33 @@ pub(crate) fn new_completed_mcp_tool_call( Box::new(PlainHistoryCell { lines }) } +pub(crate) fn new_limits_output(snapshot: &RateLimitSnapshotEvent) -> LimitsHistoryCell { + LimitsHistoryCell { + display: build_limits_view(snapshot, DEFAULT_GRID_CONFIG), + } +} + +pub(crate) fn new_limits_unavailable() -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![ + "/limits".magenta().into(), + "".into(), + vec!["Rate limit usage snapshot".bold()].into(), + vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()] + .into(), + vec![" Real usage data is not available yet.".into()].into(), + vec![" Send a message to Codex, then run /limits again.".dim()].into(), + ], + } +} + +#[allow(clippy::disallowed_methods)] +pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell { + PlainHistoryCell { + lines: vec![vec![format!("⚠ {message}").yellow()].into()], + } +} + pub(crate) fn new_status_output( config: &Config, usage: &TokenUsage, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 308e563ded..92b42732c3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -55,6 +55,7 @@ mod markdown_stream; mod new_model_popup; pub mod onboarding; mod pager_overlay; +mod rate_limits_view; mod render; mod resume_picker; mod session_log; diff --git a/codex-rs/tui/src/rate_limits_view.rs b/codex-rs/tui/src/rate_limits_view.rs new file mode 100644 index 0000000000..72fc6e9763 --- /dev/null +++ b/codex-rs/tui/src/rate_limits_view.rs @@ -0,0 +1,504 @@ +use codex_core::protocol::RateLimitSnapshotEvent; +use ratatui::prelude::*; +use ratatui::style::Stylize; + +/// Aggregated output used by the `/limits` command. +/// It contains the rendered summary lines, optional legend, +/// and the precomputed gauge state when one can be shown. +#[derive(Debug)] +pub(crate) struct LimitsView { + pub(crate) summary_lines: Vec>, + pub(crate) legend_lines: Vec>, + grid_state: Option, + grid: GridConfig, +} + +impl LimitsView { + /// Render the gauge for the provided width if the data supports it. + pub(crate) fn gauge_lines(&self, width: u16) -> Vec> { + match self.grid_state { + Some(state) => render_limit_grid(state, self.grid, width), + None => Vec::new(), + } + } +} + +/// Configuration for the simple grid gauge rendered by `/limits`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct GridConfig { + pub(crate) weekly_slots: usize, + pub(crate) logo: &'static str, +} + +/// Default gauge configuration used by the TUI. +pub(crate) const DEFAULT_GRID_CONFIG: GridConfig = GridConfig { + weekly_slots: 100, + logo: "(>_)", +}; + +/// Build the lines and optional gauge used by the `/limits` view. +pub(crate) fn build_limits_view( + snapshot: &RateLimitSnapshotEvent, + grid_config: GridConfig, +) -> LimitsView { + let metrics = RateLimitMetrics::from_snapshot(snapshot); + let grid_state = extract_capacity_fraction(snapshot) + .and_then(|fraction| compute_grid_state(&metrics, fraction)) + .map(|state| scale_grid_state(state, grid_config)); + + LimitsView { + summary_lines: build_summary_lines(&metrics), + legend_lines: build_legend_lines(grid_state.is_some()), + grid_state, + grid: grid_config, + } +} + +#[derive(Debug)] +struct RateLimitMetrics { + hourly_used: f64, + weekly_used: f64, + hourly_remaining: f64, + weekly_remaining: f64, + hourly_window_label: String, + weekly_window_label: String, + hourly_reset_hint: String, + weekly_reset_hint: String, +} + +impl RateLimitMetrics { + fn from_snapshot(snapshot: &RateLimitSnapshotEvent) -> Self { + let hourly_used = snapshot.primary_used_percent.clamp(0.0, 100.0); + let weekly_used = snapshot.weekly_used_percent.clamp(0.0, 100.0); + Self { + hourly_used, + weekly_used, + hourly_remaining: (100.0 - hourly_used).max(0.0), + weekly_remaining: (100.0 - weekly_used).max(0.0), + hourly_window_label: format_window_label(Some(snapshot.primary_window_minutes)), + weekly_window_label: format_window_label(Some(snapshot.weekly_window_minutes)), + hourly_reset_hint: format_reset_hint(Some(snapshot.primary_window_minutes)), + weekly_reset_hint: format_reset_hint(Some(snapshot.weekly_window_minutes)), + } + } + + fn hourly_exhausted(&self) -> bool { + self.hourly_remaining <= 0.0 + } + + fn weekly_exhausted(&self) -> bool { + self.weekly_remaining <= 0.0 + } +} + +fn format_window_label(minutes: Option) -> String { + approximate_duration(minutes) + .map(|(value, unit)| format!("≈{value} {} window", pluralize_unit(unit, value))) + .unwrap_or_else(|| "window unknown".to_string()) +} + +fn format_reset_hint(minutes: Option) -> String { + approximate_duration(minutes) + .map(|(value, unit)| format!("≈{value} {}", pluralize_unit(unit, value))) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn approximate_duration(minutes: Option) -> Option<(u64, DurationUnit)> { + let minutes = minutes?; + if minutes == 0 { + return Some((1, DurationUnit::Minute)); + } + if minutes < 60 { + return Some((minutes, DurationUnit::Minute)); + } + if minutes < 1_440 { + let hours = ((minutes as f64) / 60.0).round().max(1.0) as u64; + return Some((hours, DurationUnit::Hour)); + } + let days = ((minutes as f64) / 1_440.0).round().max(1.0) as u64; + if days >= 7 { + let weeks = ((days as f64) / 7.0).round().max(1.0) as u64; + Some((weeks, DurationUnit::Week)) + } else { + Some((days, DurationUnit::Day)) + } +} + +fn pluralize_unit(unit: DurationUnit, value: u64) -> String { + match unit { + DurationUnit::Minute => { + if value == 1 { + "minute".to_string() + } else { + "minutes".to_string() + } + } + DurationUnit::Hour => { + if value == 1 { + "hour".to_string() + } else { + "hours".to_string() + } + } + DurationUnit::Day => { + if value == 1 { + "day".to_string() + } else { + "days".to_string() + } + } + DurationUnit::Week => { + if value == 1 { + "week".to_string() + } else { + "weeks".to_string() + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DurationUnit { + Minute, + Hour, + Day, + Week, +} + +#[derive(Clone, Copy, Debug)] +struct GridState { + weekly_used_ratio: f64, + hourly_remaining_ratio: f64, +} + +fn build_summary_lines(metrics: &RateLimitMetrics) -> Vec> { + let mut lines: Vec> = vec![ + "/limits".magenta().into(), + "".into(), + vec!["Rate limit usage snapshot".bold()].into(), + vec![" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()].into(), + build_usage_line( + " • Hourly limit", + &metrics.hourly_window_label, + metrics.hourly_used, + ), + build_usage_line( + " • Weekly limit", + &metrics.weekly_window_label, + metrics.weekly_used, + ), + ]; + lines.push(build_status_line(metrics)); + lines +} + +fn build_usage_line(label: &str, window_label: &str, used_percent: f64) -> Line<'static> { + Line::from(vec![ + label.to_string().into(), + format!(" ({window_label})").dim(), + ": ".into(), + format!("{used_percent:.1}% used").dark_gray().bold(), + ]) +} + +fn build_status_line(metrics: &RateLimitMetrics) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + if metrics.weekly_exhausted() || metrics.hourly_exhausted() { + spans.push(" Rate limited: ".into()); + let reason = match (metrics.hourly_exhausted(), metrics.weekly_exhausted()) { + (true, true) => "weekly and hourly windows exhausted", + (true, false) => "hourly window exhausted", + (false, true) => "weekly window exhausted", + (false, false) => unreachable!(), + }; + spans.push(reason.red()); + if metrics.hourly_exhausted() { + spans.push(" — hourly resets in ".into()); + spans.push(metrics.hourly_reset_hint.clone().dim()); + } + if metrics.weekly_exhausted() { + spans.push(" — weekly resets in ".into()); + spans.push(metrics.weekly_reset_hint.clone().dim()); + } + } else { + spans.push(" Within current limits".green()); + } + Line::from(spans) +} + +fn build_legend_lines(show_gauge: bool) -> Vec> { + if !show_gauge { + return Vec::new(); + } + vec![ + vec!["Legend".bold()].into(), + vec![ + " • ".into(), + "Dark gray".dark_gray().bold(), + " = weekly usage so far".into(), + ] + .into(), + vec![ + " • ".into(), + "Green".green().bold(), + " = hourly capacity still available".into(), + ] + .into(), + vec![ + " • ".into(), + "Default".bold(), + " = weekly capacity beyond the hourly window".into(), + ] + .into(), + ] +} + +fn extract_capacity_fraction(snapshot: &RateLimitSnapshotEvent) -> Option { + let ratio = snapshot.primary_to_weekly_ratio_percent; + if ratio.is_finite() { + Some((ratio / 100.0).clamp(0.0, 1.0)) + } else { + None + } +} + +fn compute_grid_state(metrics: &RateLimitMetrics, capacity_fraction: f64) -> Option { + if capacity_fraction <= 0.0 { + return None; + } + + let weekly_used_ratio = (metrics.weekly_used / 100.0).clamp(0.0, 1.0); + let weekly_remaining_ratio = (1.0 - weekly_used_ratio).max(0.0); + + let hourly_used_ratio = (metrics.hourly_used / 100.0).clamp(0.0, 1.0); + let hourly_used_within_capacity = + (hourly_used_ratio * capacity_fraction).min(capacity_fraction); + let hourly_remaining_within_capacity = + (capacity_fraction - hourly_used_within_capacity).max(0.0); + + let hourly_remaining_ratio = hourly_remaining_within_capacity.min(weekly_remaining_ratio); + + Some(GridState { + weekly_used_ratio, + hourly_remaining_ratio, + }) +} + +fn scale_grid_state(state: GridState, grid: GridConfig) -> GridState { + if grid.weekly_slots == 0 { + return GridState { + weekly_used_ratio: 0.0, + hourly_remaining_ratio: 0.0, + }; + } + state +} + +/// Convert the grid state to rendered lines for the TUI. +fn render_limit_grid(state: GridState, grid_config: GridConfig, width: u16) -> Vec> { + GridLayout::new(grid_config, width) + .map(|layout| layout.render(state)) + .unwrap_or_default() +} + +/// Precomputed layout information for the usage grid. +struct GridLayout { + size: usize, + inner_width: usize, + config: GridConfig, +} + +impl GridLayout { + const MIN_SIDE: usize = 4; + const MAX_SIDE: usize = 12; + const PREFIX: &'static str = " "; + + fn new(config: GridConfig, width: u16) -> Option { + if config.weekly_slots == 0 || config.logo.is_empty() { + return None; + } + let cell_width = config.logo.chars().count(); + if cell_width == 0 { + return None; + } + + let available_inner = width.saturating_sub((Self::PREFIX.len() + 2) as u16) as usize; + if available_inner == 0 { + return None; + } + + let base_side = (config.weekly_slots as f64) + .sqrt() + .round() + .clamp(1.0, Self::MAX_SIDE as f64) as usize; + let width_limited_side = + ((available_inner + 1) / (cell_width + 1)).clamp(1, Self::MAX_SIDE); + + let mut side = base_side.min(width_limited_side); + if width_limited_side >= Self::MIN_SIDE { + side = side.max(Self::MIN_SIDE.min(width_limited_side)); + } + let side = side.clamp(1, Self::MAX_SIDE); + if side == 0 { + return None; + } + + let inner_width = side * cell_width + side.saturating_sub(1); + Some(Self { + size: side, + inner_width, + config, + }) + } + + /// Render the grid into styled lines for the history cell. + fn render(&self, state: GridState) -> Vec> { + let counts = self.cell_counts(state); + let mut lines = Vec::new(); + lines.push("".into()); + lines.push(self.render_border('╭', '╮')); + + let mut cell_index = 0isize; + for _ in 0..self.size { + let mut spans: Vec> = Vec::new(); + spans.push(Self::PREFIX.into()); + spans.push("│".dim()); + + for col in 0..self.size { + if col > 0 { + spans.push(" ".into()); + } + let span = if cell_index < counts.dark_cells { + self.config.logo.dark_gray() + } else if cell_index < counts.dark_cells + counts.green_cells { + self.config.logo.green() + } else { + self.config.logo.into() + }; + spans.push(span); + cell_index += 1; + } + + spans.push("│".dim()); + lines.push(Line::from(spans)); + } + + lines.push(self.render_border('╰', '╯')); + lines.push("".into()); + + if counts.white_cells == 0 { + lines.push(vec![" (No unused weekly capacity remaining)".dim()].into()); + lines.push("".into()); + } + + lines + } + + fn render_border(&self, left: char, right: char) -> Line<'static> { + let mut text = String::from(Self::PREFIX); + text.push(left); + text.push_str(&"─".repeat(self.inner_width)); + text.push(right); + vec![Span::from(text).dim()].into() + } + + /// Translate usage ratios into the number of coloured cells. + fn cell_counts(&self, state: GridState) -> GridCellCounts { + let total_cells = self.size * self.size; + let mut dark_cells = (state.weekly_used_ratio * total_cells as f64).round() as isize; + dark_cells = dark_cells.clamp(0, total_cells as isize); + let mut green_cells = (state.hourly_remaining_ratio * total_cells as f64).round() as isize; + if dark_cells + green_cells > total_cells as isize { + green_cells = (total_cells as isize - dark_cells).max(0); + } + let white_cells = (total_cells as isize - dark_cells - green_cells).max(0); + + GridCellCounts { + dark_cells, + green_cells, + white_cells, + } + } +} + +/// Number of weekly (dark), hourly (green) and unused (default) cells. +struct GridCellCounts { + dark_cells: isize, + green_cells: isize, + white_cells: isize, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> RateLimitSnapshotEvent { + RateLimitSnapshotEvent { + primary_used_percent: 30.0, + weekly_used_percent: 60.0, + primary_to_weekly_ratio_percent: 40.0, + primary_window_minutes: 300, + weekly_window_minutes: 10_080, + } + } + + #[test] + fn approximate_duration_handles_hours_and_weeks() { + assert_eq!( + approximate_duration(Some(299)), + Some((5, DurationUnit::Hour)) + ); + assert_eq!( + approximate_duration(Some(10_080)), + Some((1, DurationUnit::Week)) + ); + assert_eq!( + approximate_duration(Some(90)), + Some((2, DurationUnit::Hour)) + ); + } + + #[test] + fn build_display_constructs_summary_and_gauge() { + let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); + assert!(display.summary_lines.iter().any(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Weekly limit")) + })); + assert!(display.summary_lines.iter().any(|line| { + line.spans + .iter() + .any(|span| span.content.contains("Hourly limit")) + })); + assert!(!display.gauge_lines(80).is_empty()); + } + + #[test] + fn hourly_and_weekly_percentages_are_not_swapped() { + let display = build_limits_view(&snapshot(), DEFAULT_GRID_CONFIG); + let summary = display + .summary_lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(summary.contains("Hourly limit (≈5 hours window): 30.0% used")); + assert!(summary.contains("Weekly limit (≈1 week window): 60.0% used")); + } + + #[test] + fn build_display_without_ratio_skips_gauge() { + let mut s = snapshot(); + s.primary_to_weekly_ratio_percent = f64::NAN; + let display = build_limits_view(&s, DEFAULT_GRID_CONFIG); + assert!(display.gauge_lines(80).is_empty()); + assert!(display.legend_lines.is_empty()); + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 433c0a6d7f..4d4c806727 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -21,6 +21,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Limits, Mcp, Logout, Quit, @@ -40,6 +41,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Limits => "visualize weekly and hourly rate limits", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", @@ -68,6 +70,7 @@ impl SlashCommand { SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Limits | SlashCommand::Mcp | SlashCommand::Quit => true,