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

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