Tui: Rate limits (#3977)

### /limits: show rate limits graph

<img width="442" height="287" alt="image"
src="https://github.com/user-attachments/assets/3e29a241-a4b0-4df8-bf71-43dc4dd805ca"
/>

### Warning on close to rate limits:

<img width="507" height="96" alt="image"
src="https://github.com/user-attachments/assets/732a958b-d240-4a89-8289-caa92de83537"
/>

Based on #3965
This commit is contained in:
Ahmed Ibrahim
2025-09-21 10:20:49 -07:00
committed by GitHub
parent 04504d8218
commit a4ebd069e5
11 changed files with 910 additions and 1 deletions

View File

@@ -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<ParsedCommand>,
}
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<String> {
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<UserMessage>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
rate_limit_warnings: RateLimitWarningState,
// Stream lifecycle controller
stream: StreamController,
running_commands: HashMap<String, RunningCommand>,
@@ -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<RateLimitSnapshotEvent>) {
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 {

View File

@@ -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.[/]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<RateLimitSnapshotEvent>) -> 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<String> = 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]

View File

@@ -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<Line<'static>> {
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<Line<'static>>,
@@ -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,

View File

@@ -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;

View File

@@ -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<Line<'static>>,
pub(crate) legend_lines: Vec<Line<'static>>,
grid_state: Option<GridState>,
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<Line<'static>> {
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<u64>) -> 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<u64>) -> String {
approximate_duration(minutes)
.map(|(value, unit)| format!("{value} {}", pluralize_unit(unit, value)))
.unwrap_or_else(|| "unknown".to_string())
}
fn approximate_duration(minutes: Option<u64>) -> 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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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<Span<'static>> = 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<Line<'static>> {
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<f64> {
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<GridState> {
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<Line<'static>> {
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<Self> {
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<Line<'static>> {
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<Span<'static>> = 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::<String>()
})
.collect::<Vec<_>>()
.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());
}
}

View File

@@ -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,