mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/limits): expose /limits rate limit view
This commit is contained in:
@@ -1300,6 +1300,11 @@ impl App<'_> {
|
||||
widget.add_status_output();
|
||||
}
|
||||
}
|
||||
SlashCommand::Limits => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_limits_output();
|
||||
}
|
||||
}
|
||||
SlashCommand::Update => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_update_command();
|
||||
|
||||
@@ -215,6 +215,47 @@ struct RunningCommand {
|
||||
wait_notes: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.weekly_index = 0;
|
||||
self.hourly_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -226,7 +267,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
initial_user_message: Option<UserMessage>,
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
latest_rate_limits: Option<RateLimitSnapshotEvent>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
content_buffer: String,
|
||||
// Buffer for streaming assistant answer text; we do not surface partial
|
||||
// We wait for the final AgentMessage event and then emit the full text
|
||||
@@ -2030,7 +2072,8 @@ impl ChatWidget<'_> {
|
||||
),
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
latest_rate_limits: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -2222,7 +2265,8 @@ impl ChatWidget<'_> {
|
||||
initial_user_message: None,
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
latest_rate_limits: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState {
|
||||
@@ -4142,7 +4186,16 @@ impl ChatWidget<'_> {
|
||||
self.last_token_usage = info.last_token_usage.clone();
|
||||
}
|
||||
if let Some(snapshot) = event.rate_limits {
|
||||
self.latest_rate_limits = Some(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.history_push(history_cell::new_warning_event(warning));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
self.bottom_pane.set_token_usage(
|
||||
self.total_token_usage.clone(),
|
||||
@@ -5005,6 +5058,14 @@ impl ChatWidget<'_> {
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) fn add_limits_output(&mut self) {
|
||||
if let Some(snapshot) = &self.rate_limit_snapshot {
|
||||
self.history_push(history_cell::new_limits_output(snapshot));
|
||||
} else {
|
||||
self.history_push(history_cell::new_limits_unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub(crate) fn handle_update_command(&mut self) {
|
||||
self.show_update_settings_ui();
|
||||
@@ -10483,6 +10544,8 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn clear_token_usage(&mut self) {
|
||||
self.total_token_usage = TokenUsage::default();
|
||||
self.rate_limit_snapshot = None;
|
||||
self.rate_limit_warnings.reset();
|
||||
self.bottom_pane.set_token_usage(
|
||||
self.total_token_usage.clone(),
|
||||
self.last_token_usage.clone(),
|
||||
|
||||
@@ -131,7 +131,8 @@ fn make_chatwidget_manual() -> (
|
||||
initial_user_message: None,
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
latest_rate_limits: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: Default::default(),
|
||||
stream: StreamController::new(cfg),
|
||||
last_stream_kind: None,
|
||||
running_commands: HashMap::new(),
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::sanitize::Mode as SanitizeMode;
|
||||
use crate::sanitize::Options as SanitizeOptions;
|
||||
use crate::sanitize::sanitize_for_tui;
|
||||
use crate::rate_limits_view::{build_limits_view, LimitsView, DEFAULT_GRID_CONFIG};
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::util::buffer::{fill_rect, write_line};
|
||||
use crate::insert_history::word_wrap_lines;
|
||||
@@ -19,6 +20,7 @@ use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_protocol::num_format::format_with_separators;
|
||||
@@ -1081,6 +1083,60 @@ pub(crate) struct PlainHistoryCell {
|
||||
cached_layout: std::cell::RefCell<Option<PlainLayoutCache>>,
|
||||
}
|
||||
|
||||
pub(crate) struct LimitsHistoryCell {
|
||||
view: LimitsView,
|
||||
}
|
||||
|
||||
impl LimitsHistoryCell {
|
||||
const TRANSCRIPT_WIDTH: u16 = 80;
|
||||
|
||||
fn lines_for_width(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines = self.view.summary_lines.clone();
|
||||
lines.extend(self.view.gauge_lines(width));
|
||||
lines.extend(self.view.legend_lines.clone());
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for LimitsHistoryCell {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn kind(&self) -> HistoryCellType {
|
||||
HistoryCellType::Notice
|
||||
}
|
||||
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
self.lines_for_width(Self::TRANSCRIPT_WIDTH)
|
||||
}
|
||||
|
||||
fn has_custom_render(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
|
||||
let width = if area.width == 0 { 1 } else { area.width };
|
||||
let lines = self.lines_for_width(width);
|
||||
let text = Text::from(lines);
|
||||
|
||||
let cell_bg = crate::colors::background();
|
||||
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
|
||||
fill_rect(buf, area, Some(' '), bg_style);
|
||||
|
||||
Paragraph::new(text)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((skip_rows, 0))
|
||||
.block(Block::default().style(Style::default().bg(cell_bg)))
|
||||
.style(Style::default().bg(cell_bg))
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
struct PlainLayoutCache {
|
||||
requested_width: u16,
|
||||
effective_width: u16,
|
||||
@@ -9485,6 +9541,31 @@ pub(crate) fn new_status_output(
|
||||
PlainHistoryCell::new(lines, HistoryCellType::Notice)
|
||||
}
|
||||
|
||||
pub(crate) fn new_limits_output(snapshot: &RateLimitSnapshotEvent) -> LimitsHistoryCell {
|
||||
LimitsHistoryCell {
|
||||
view: build_limits_view(snapshot, DEFAULT_GRID_CONFIG),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_limits_unavailable() -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("/limits").fg(crate::colors::keyword()));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("Rate limit usage snapshot".bold()));
|
||||
lines.push(Line::from(" Tip: run `/limits` right after Codex replies for freshest numbers.".dim()));
|
||||
lines.push(Line::from(" Real usage data is not available yet."));
|
||||
lines.push(Line::from(" Send a message to Codex, then run /limits again.".dim()));
|
||||
PlainHistoryCell::new(lines, HistoryCellType::Notice)
|
||||
}
|
||||
|
||||
pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
|
||||
let warn_style = Style::default().fg(crate::colors::warning());
|
||||
PlainHistoryCell::new(
|
||||
vec![Line::from(vec![Span::styled(format!("⚠ {message}"), warn_style)])],
|
||||
HistoryCellType::Notice,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_prompts_output() -> PlainHistoryCell {
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from("/prompts").fg(crate::colors::keyword()),
|
||||
|
||||
@@ -56,6 +56,7 @@ mod render;
|
||||
mod session_log;
|
||||
mod shimmer;
|
||||
mod slash_command;
|
||||
mod rate_limits_view;
|
||||
mod resume;
|
||||
mod streaming;
|
||||
mod sanitize;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub enum SlashCommand {
|
||||
Mention,
|
||||
Cmd,
|
||||
Status,
|
||||
Limits,
|
||||
Update,
|
||||
Theme,
|
||||
Model,
|
||||
@@ -69,6 +70,7 @@ impl SlashCommand {
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Cmd => "run a project command",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Limits => "visualize weekly and hourly rate limits",
|
||||
SlashCommand::Update => "check for updates and optionally upgrade",
|
||||
SlashCommand::Theme => "switch between color themes",
|
||||
SlashCommand::Prompts => "show example prompts",
|
||||
|
||||
@@ -39,6 +39,7 @@ Notes
|
||||
- `/reasoning (minimal|low|medium|high)`: change reasoning effort.
|
||||
- `/prompts`: show example prompts.
|
||||
- `/status`: show current session configuration and token usage.
|
||||
- `/limits`: visualize current hourly and weekly rate-limit usage.
|
||||
- `/update`: check the installed version, detect available upgrades, and run the
|
||||
appropriate installer when possible.
|
||||
- `/mcp`: manage MCP servers (status/on/off/add).
|
||||
|
||||
Reference in New Issue
Block a user