Skip to content

Commit a4ebd06

Browse files
authored
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
1 parent 04504d8 commit a4ebd06

11 files changed

+910
-1
lines changed

codex-rs/tui/src/chatwidget.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
2828
use codex_core::protocol::McpToolCallEndEvent;
2929
use codex_core::protocol::Op;
3030
use codex_core::protocol::PatchApplyBeginEvent;
31+
use codex_core::protocol::RateLimitSnapshotEvent;
3132
use codex_core::protocol::ReviewRequest;
3233
use codex_core::protocol::StreamErrorEvent;
3334
use codex_core::protocol::TaskCompleteEvent;
@@ -103,6 +104,42 @@ struct RunningCommand {
103104
parsed_cmd: Vec<ParsedCommand>,
104105
}
105106

107+
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
108+
109+
#[derive(Default)]
110+
struct RateLimitWarningState {
111+
weekly_index: usize,
112+
hourly_index: usize,
113+
}
114+
115+
impl RateLimitWarningState {
116+
fn take_warnings(&mut self, weekly_used_percent: f64, hourly_used_percent: f64) -> Vec<String> {
117+
let mut warnings = Vec::new();
118+
119+
while self.weekly_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
120+
&& weekly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index]
121+
{
122+
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.weekly_index];
123+
warnings.push(format!(
124+
"Weekly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
125+
));
126+
self.weekly_index += 1;
127+
}
128+
129+
while self.hourly_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
130+
&& hourly_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index]
131+
{
132+
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.hourly_index];
133+
warnings.push(format!(
134+
"Hourly usage exceeded {threshold:.0}% of the limit. Run /limits for detailed usage."
135+
));
136+
self.hourly_index += 1;
137+
}
138+
139+
warnings
140+
}
141+
}
142+
106143
/// Common initialization parameters shared by all `ChatWidget` constructors.
107144
pub(crate) struct ChatWidgetInit {
108145
pub(crate) config: Config,
@@ -124,6 +161,8 @@ pub(crate) struct ChatWidget {
124161
session_header: SessionHeader,
125162
initial_user_message: Option<UserMessage>,
126163
token_info: Option<TokenUsageInfo>,
164+
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
165+
rate_limit_warnings: RateLimitWarningState,
127166
// Stream lifecycle controller
128167
stream: StreamController,
129168
running_commands: HashMap<String, RunningCommand>,
@@ -285,6 +324,21 @@ impl ChatWidget {
285324
self.bottom_pane.set_token_usage(info.clone());
286325
self.token_info = info;
287326
}
327+
328+
fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshotEvent>) {
329+
if let Some(snapshot) = snapshot {
330+
let warnings = self
331+
.rate_limit_warnings
332+
.take_warnings(snapshot.weekly_used_percent, snapshot.primary_used_percent);
333+
self.rate_limit_snapshot = Some(snapshot);
334+
if !warnings.is_empty() {
335+
for warning in warnings {
336+
self.add_to_history(history_cell::new_warning_event(warning));
337+
}
338+
self.request_redraw();
339+
}
340+
}
341+
}
288342
/// Finalize any active exec as failed and stop/clear running UI state.
289343
fn finalize_turn(&mut self) {
290344
// Ensure any spinner is replaced by a red ✗ and flushed into history.
@@ -699,6 +753,8 @@ impl ChatWidget {
699753
initial_images,
700754
),
701755
token_info: None,
756+
rate_limit_snapshot: None,
757+
rate_limit_warnings: RateLimitWarningState::default(),
702758
stream: StreamController::new(config),
703759
running_commands: HashMap::new(),
704760
task_complete_pending: false,
@@ -756,6 +812,8 @@ impl ChatWidget {
756812
initial_images,
757813
),
758814
token_info: None,
815+
rate_limit_snapshot: None,
816+
rate_limit_warnings: RateLimitWarningState::default(),
759817
stream: StreamController::new(config),
760818
running_commands: HashMap::new(),
761819
task_complete_pending: false,
@@ -929,6 +987,9 @@ impl ChatWidget {
929987
SlashCommand::Status => {
930988
self.add_status_output();
931989
}
990+
SlashCommand::Limits => {
991+
self.add_limits_output();
992+
}
932993
SlashCommand::Mcp => {
933994
self.add_mcp_output();
934995
}
@@ -1106,7 +1167,10 @@ impl ChatWidget {
11061167
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
11071168
self.on_task_complete(last_agent_message)
11081169
}
1109-
EventMsg::TokenCount(ev) => self.set_token_info(ev.info),
1170+
EventMsg::TokenCount(ev) => {
1171+
self.set_token_info(ev.info);
1172+
self.on_rate_limit_snapshot(ev.rate_limits);
1173+
}
11101174
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
11111175
EventMsg::TurnAborted(ev) => match ev.reason {
11121176
TurnAbortReason::Interrupted => {
@@ -1282,6 +1346,15 @@ impl ChatWidget {
12821346
self.request_redraw();
12831347
}
12841348

1349+
pub(crate) fn add_limits_output(&mut self) {
1350+
if let Some(snapshot) = &self.rate_limit_snapshot {
1351+
self.add_to_history(history_cell::new_limits_output(snapshot));
1352+
} else {
1353+
self.add_to_history(history_cell::new_limits_unavailable());
1354+
}
1355+
self.request_redraw();
1356+
}
1357+
12851358
pub(crate) fn add_status_output(&mut self) {
12861359
let default_usage;
12871360
let usage_ref = if let Some(ti) = &self.token_info {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: visual
4+
---
5+
[magenta]/limits[/]
6+
7+
[bold]Rate limit usage snapshot[/]
8+
[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/]
9+
Real usage data is not available yet.
10+
[dim] Send a message to Codex, then run /limits again.[/]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: visual
4+
---
5+
[magenta]/limits[/]
6+
7+
[bold]Rate limit usage snapshot[/]
8+
[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/]
9+
Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]30.0% used[/]
10+
Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]60.0% used[/]
11+
[green] Within current limits[/]
12+
13+
[dim] ╭─────────────────────────────────────────────────╮[/]
14+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
15+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
16+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
17+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
18+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
19+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
20+
[dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/]
21+
[dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/]
22+
[dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/]
23+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
24+
[dim] ╰─────────────────────────────────────────────────╯[/]
25+
26+
[bold]Legend[/]
27+
• [dark-gray+bold]Dark gray[/] = weekly usage so far
28+
• [green+bold]Green[/] = hourly capacity still available
29+
• [bold]Default[/] = weekly capacity beyond the hourly window
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: visual
4+
---
5+
[magenta]/limits[/]
6+
7+
[bold]Rate limit usage snapshot[/]
8+
[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/]
9+
Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]0.0% used[/]
10+
Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/]
11+
[green] Within current limits[/]
12+
13+
[dim] ╭─────────────────────────────────────────────────╮[/]
14+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
15+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
16+
[dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/]
17+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
18+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
19+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
20+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
21+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
22+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
23+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
24+
[dim] ╰─────────────────────────────────────────────────╯[/]
25+
26+
[bold]Legend[/]
27+
• [dark-gray+bold]Dark gray[/] = weekly usage so far
28+
• [green+bold]Green[/] = hourly capacity still available
29+
• [bold]Default[/] = weekly capacity beyond the hourly window
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: visual
4+
---
5+
[magenta]/limits[/]
6+
7+
[bold]Rate limit usage snapshot[/]
8+
[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/]
9+
Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]20.0% used[/]
10+
Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/]
11+
[green] Within current limits[/]
12+
13+
[dim] ╭─────────────────────────────────────────────────╮[/]
14+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
15+
[dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/]
16+
[dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/]
17+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
18+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
19+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
20+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
21+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
22+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
23+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
24+
[dim] ╰─────────────────────────────────────────────────╯[/]
25+
26+
[bold]Legend[/]
27+
• [dark-gray+bold]Dark gray[/] = weekly usage so far
28+
• [green+bold]Green[/] = hourly capacity still available
29+
• [bold]Default[/] = weekly capacity beyond the hourly window
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
source: tui/src/chatwidget/tests.rs
3+
expression: visual
4+
---
5+
[magenta]/limits[/]
6+
7+
[bold]Rate limit usage snapshot[/]
8+
[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/]
9+
Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]98.0% used[/]
10+
Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]0.0% used[/]
11+
[green] Within current limits[/]
12+
13+
[dim] ╭─────────────────────────────────────────────────╮[/]
14+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
15+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
16+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
17+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
18+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
19+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
20+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
21+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
22+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
23+
[dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/]
24+
[dim] ╰─────────────────────────────────────────────────╯[/]
25+
26+
[bold]Legend[/]
27+
• [dark-gray+bold]Dark gray[/] = weekly usage so far
28+
• [green+bold]Green[/] = hourly capacity still available
29+
• [bold]Default[/] = weekly capacity beyond the hourly window

0 commit comments

Comments
 (0)