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::McpToolCallEndEvent;
|
||||||
use codex_core::protocol::Op;
|
use codex_core::protocol::Op;
|
||||||
use codex_core::protocol::PatchApplyBeginEvent;
|
use codex_core::protocol::PatchApplyBeginEvent;
|
||||||
|
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||||
use codex_core::protocol::ReviewRequest;
|
use codex_core::protocol::ReviewRequest;
|
||||||
use codex_core::protocol::StreamErrorEvent;
|
use codex_core::protocol::StreamErrorEvent;
|
||||||
use codex_core::protocol::TaskCompleteEvent;
|
use codex_core::protocol::TaskCompleteEvent;
|
||||||
@@ -103,6 +104,42 @@ struct RunningCommand {
|
|||||||
parsed_cmd: Vec<ParsedCommand>,
|
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.
|
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||||||
pub(crate) struct ChatWidgetInit {
|
pub(crate) struct ChatWidgetInit {
|
||||||
pub(crate) config: Config,
|
pub(crate) config: Config,
|
||||||
@@ -124,6 +161,8 @@ pub(crate) struct ChatWidget {
|
|||||||
session_header: SessionHeader,
|
session_header: SessionHeader,
|
||||||
initial_user_message: Option<UserMessage>,
|
initial_user_message: Option<UserMessage>,
|
||||||
token_info: Option<TokenUsageInfo>,
|
token_info: Option<TokenUsageInfo>,
|
||||||
|
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
|
||||||
|
rate_limit_warnings: RateLimitWarningState,
|
||||||
// Stream lifecycle controller
|
// Stream lifecycle controller
|
||||||
stream: StreamController,
|
stream: StreamController,
|
||||||
running_commands: HashMap<String, RunningCommand>,
|
running_commands: HashMap<String, RunningCommand>,
|
||||||
@@ -285,6 +324,21 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.set_token_usage(info.clone());
|
self.bottom_pane.set_token_usage(info.clone());
|
||||||
self.token_info = info;
|
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.
|
/// Finalize any active exec as failed and stop/clear running UI state.
|
||||||
fn finalize_turn(&mut self) {
|
fn finalize_turn(&mut self) {
|
||||||
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
||||||
@@ -699,6 +753,8 @@ impl ChatWidget {
|
|||||||
initial_images,
|
initial_images,
|
||||||
),
|
),
|
||||||
token_info: None,
|
token_info: None,
|
||||||
|
rate_limit_snapshot: None,
|
||||||
|
rate_limit_warnings: RateLimitWarningState::default(),
|
||||||
stream: StreamController::new(config),
|
stream: StreamController::new(config),
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
@@ -756,6 +812,8 @@ impl ChatWidget {
|
|||||||
initial_images,
|
initial_images,
|
||||||
),
|
),
|
||||||
token_info: None,
|
token_info: None,
|
||||||
|
rate_limit_snapshot: None,
|
||||||
|
rate_limit_warnings: RateLimitWarningState::default(),
|
||||||
stream: StreamController::new(config),
|
stream: StreamController::new(config),
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
@@ -929,6 +987,9 @@ impl ChatWidget {
|
|||||||
SlashCommand::Status => {
|
SlashCommand::Status => {
|
||||||
self.add_status_output();
|
self.add_status_output();
|
||||||
}
|
}
|
||||||
|
SlashCommand::Limits => {
|
||||||
|
self.add_limits_output();
|
||||||
|
}
|
||||||
SlashCommand::Mcp => {
|
SlashCommand::Mcp => {
|
||||||
self.add_mcp_output();
|
self.add_mcp_output();
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1167,10 @@ impl ChatWidget {
|
|||||||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||||||
self.on_task_complete(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::Error(ErrorEvent { message }) => self.on_error(message),
|
||||||
EventMsg::TurnAborted(ev) => match ev.reason {
|
EventMsg::TurnAborted(ev) => match ev.reason {
|
||||||
TurnAbortReason::Interrupted => {
|
TurnAbortReason::Interrupted => {
|
||||||
@@ -1282,6 +1346,15 @@ impl ChatWidget {
|
|||||||
self.request_redraw();
|
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) {
|
pub(crate) fn add_status_output(&mut self) {
|
||||||
let default_usage;
|
let default_usage;
|
||||||
let usage_ref = if let Some(ti) = &self.token_info {
|
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::Op;
|
||||||
use codex_core::protocol::PatchApplyBeginEvent;
|
use codex_core::protocol::PatchApplyBeginEvent;
|
||||||
use codex_core::protocol::PatchApplyEndEvent;
|
use codex_core::protocol::PatchApplyEndEvent;
|
||||||
|
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||||
use codex_core::protocol::ReviewCodeLocation;
|
use codex_core::protocol::ReviewCodeLocation;
|
||||||
use codex_core::protocol::ReviewFinding;
|
use codex_core::protocol::ReviewFinding;
|
||||||
use codex_core::protocol::ReviewLineRange;
|
use codex_core::protocol::ReviewLineRange;
|
||||||
@@ -39,6 +40,8 @@ use crossterm::event::KeyEvent;
|
|||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use ratatui::style::Modifier;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
@@ -320,6 +323,8 @@ fn make_chatwidget_manual() -> (
|
|||||||
session_header: SessionHeader::new(cfg.model.clone()),
|
session_header: SessionHeader::new(cfg.model.clone()),
|
||||||
initial_user_message: None,
|
initial_user_message: None,
|
||||||
token_info: None,
|
token_info: None,
|
||||||
|
rate_limit_snapshot: None,
|
||||||
|
rate_limit_warnings: RateLimitWarningState::default(),
|
||||||
stream: StreamController::new(cfg),
|
stream: StreamController::new(cfg),
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
task_complete_pending: false,
|
task_complete_pending: false,
|
||||||
@@ -375,6 +380,158 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
|
|||||||
s
|
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)
|
// (removed experimental resize snapshot test)
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use crate::diff_render::create_diff_summary;
|
|||||||
use crate::exec_command::relativize_to_home;
|
use crate::exec_command::relativize_to_home;
|
||||||
use crate::exec_command::strip_bash_lc_and_escape;
|
use crate::exec_command::strip_bash_lc_and_escape;
|
||||||
use crate::markdown::append_markdown;
|
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::line_to_static;
|
||||||
use crate::render::line_utils::prefix_lines;
|
use crate::render::line_utils::prefix_lines;
|
||||||
use crate::render::line_utils::push_owned_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::project_doc::discover_project_doc_paths;
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
use codex_core::protocol::McpInvocation;
|
use codex_core::protocol::McpInvocation;
|
||||||
|
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_core::protocol::SessionConfiguredEvent;
|
use codex_core::protocol::SessionConfiguredEvent;
|
||||||
use codex_core::protocol::TokenUsage;
|
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)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct TranscriptOnlyHistoryCell {
|
pub(crate) struct TranscriptOnlyHistoryCell {
|
||||||
lines: Vec<Line<'static>>,
|
lines: Vec<Line<'static>>,
|
||||||
@@ -1075,6 +1093,33 @@ pub(crate) fn new_completed_mcp_tool_call(
|
|||||||
Box::new(PlainHistoryCell { lines })
|
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(
|
pub(crate) fn new_status_output(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
usage: &TokenUsage,
|
usage: &TokenUsage,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ mod markdown_stream;
|
|||||||
mod new_model_popup;
|
mod new_model_popup;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
mod pager_overlay;
|
mod pager_overlay;
|
||||||
|
mod rate_limits_view;
|
||||||
mod render;
|
mod render;
|
||||||
mod resume_picker;
|
mod resume_picker;
|
||||||
mod session_log;
|
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,
|
Diff,
|
||||||
Mention,
|
Mention,
|
||||||
Status,
|
Status,
|
||||||
|
Limits,
|
||||||
Mcp,
|
Mcp,
|
||||||
Logout,
|
Logout,
|
||||||
Quit,
|
Quit,
|
||||||
@@ -40,6 +41,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
SlashCommand::Mention => "mention a file",
|
SlashCommand::Mention => "mention a file",
|
||||||
SlashCommand::Status => "show current session configuration and token usage",
|
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::Model => "choose what model and reasoning effort to use",
|
||||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||||
SlashCommand::Mcp => "list configured MCP tools",
|
SlashCommand::Mcp => "list configured MCP tools",
|
||||||
@@ -68,6 +70,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::Diff
|
SlashCommand::Diff
|
||||||
| SlashCommand::Mention
|
| SlashCommand::Mention
|
||||||
| SlashCommand::Status
|
| SlashCommand::Status
|
||||||
|
| SlashCommand::Limits
|
||||||
| SlashCommand::Mcp
|
| SlashCommand::Mcp
|
||||||
| SlashCommand::Quit => true,
|
| SlashCommand::Quit => true,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user