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:
@@ -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 {
|
||||
|
||||
@@ -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.[/]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
504
codex-rs/tui/src/rate_limits_view.rs
Normal file
504
codex-rs/tui/src/rate_limits_view.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user