mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow, and #22433 completed phase 3 by extracting protocol, replay, streaming, and tool lifecycle handling. This PR is phase 4. It keeps moving high-churn UI coordination out of the central widget by extracting settings, popups, and status surfaces without changing the visible behavior those flows already provide. This is once again a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for runtime settings/model coordination, model/reasoning/collaboration popups, settings/personality/theme/audio/experimental popups, permission prompts, status setup/output controls, and Windows sandbox prompt flows. - Moved the remaining rate-limit nudge/status helpers and connectors popup/loading/update helpers into their existing focused modules. - Preserved the existing picker flows, approval behavior, status/title setup previews, rate-limit notices, and connectors/app list behavior while shrinking `chatwidget.rs` back toward orchestration. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and composition surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. Completed in #22433. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. This PR. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. ## Verification - `cargo check -p codex-tui` - `cargo test -p codex-tui chatwidget::tests::permissions` - `cargo test -p codex-tui chatwidget::tests::status_surface_previews` - `cargo test -p codex-tui chatwidget::tests::popups_and_settings` - `cargo test -p codex-tui chatwidget::tests::status_and_layout` `cargo test -p codex-tui` also compiles and begins running, but aborts in the unchanged app-side test `app::tests::discard_side_thread_keeps_local_state_when_server_close_fails` with a reproducible stack overflow.
453 lines
17 KiB
Rust
453 lines
17 KiB
Rust
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
|
|
|
|
use super::*;
|
|
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
|
|
|
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini";
|
|
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
|
|
|
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct RateLimitWarningState {
|
|
pub(super) secondary_index: usize,
|
|
pub(super) primary_index: usize,
|
|
}
|
|
|
|
impl RateLimitWarningState {
|
|
pub(super) fn take_warnings(
|
|
&mut self,
|
|
secondary_used_percent: Option<f64>,
|
|
secondary_window_minutes: Option<i64>,
|
|
primary_used_percent: Option<f64>,
|
|
primary_window_minutes: Option<i64>,
|
|
) -> Vec<String> {
|
|
let reached_secondary_cap =
|
|
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
|
|
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
|
|
if reached_secondary_cap || reached_primary_cap {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut warnings = Vec::new();
|
|
|
|
if let Some(secondary_used_percent) = secondary_used_percent {
|
|
let mut highest_secondary: Option<f64> = None;
|
|
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
|
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
|
{
|
|
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
|
self.secondary_index += 1;
|
|
}
|
|
if let Some(threshold) = highest_secondary {
|
|
let limit_label = secondary_window_minutes
|
|
.map(get_limits_duration)
|
|
.unwrap_or_else(|| "weekly".to_string());
|
|
let remaining_percent = 100.0 - threshold;
|
|
warnings.push(format!(
|
|
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(primary_used_percent) = primary_used_percent {
|
|
let mut highest_primary: Option<f64> = None;
|
|
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
|
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
|
{
|
|
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
|
self.primary_index += 1;
|
|
}
|
|
if let Some(threshold) = highest_primary {
|
|
let limit_label = primary_window_minutes
|
|
.map(get_limits_duration)
|
|
.unwrap_or_else(|| "5h".to_string());
|
|
let remaining_percent = 100.0 - threshold;
|
|
warnings.push(format!(
|
|
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
|
|
));
|
|
}
|
|
}
|
|
|
|
warnings
|
|
}
|
|
}
|
|
|
|
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
|
|
const MINUTES_PER_HOUR: i64 = 60;
|
|
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
|
|
const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;
|
|
const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;
|
|
const ROUNDING_BIAS_MINUTES: i64 = 3;
|
|
|
|
let windows_minutes = windows_minutes.max(0);
|
|
|
|
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
|
|
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
|
|
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
|
|
format!("{hours}h")
|
|
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
|
|
"weekly".to_string()
|
|
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
|
|
"monthly".to_string()
|
|
} else {
|
|
"annual".to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(super) enum RateLimitSwitchPromptState {
|
|
#[default]
|
|
Idle,
|
|
Pending,
|
|
Shown,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) enum RateLimitErrorKind {
|
|
ServerOverloaded,
|
|
UsageLimit,
|
|
Generic,
|
|
}
|
|
|
|
pub(super) fn app_server_rate_limit_error_kind(
|
|
info: &AppServerCodexErrorInfo,
|
|
) -> Option<RateLimitErrorKind> {
|
|
match info {
|
|
AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
|
|
AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
|
|
AppServerCodexErrorInfo::ResponseTooManyFailedAttempts {
|
|
http_status_code: Some(429),
|
|
} => Some(RateLimitErrorKind::Generic),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
|
|
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
|
}
|
|
|
|
impl ChatWidget {
|
|
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
|
if let Some(mut snapshot) = snapshot {
|
|
let limit_id = snapshot
|
|
.limit_id
|
|
.clone()
|
|
.unwrap_or_else(|| "codex".to_string());
|
|
let limit_label = snapshot
|
|
.limit_name
|
|
.clone()
|
|
.unwrap_or_else(|| limit_id.clone());
|
|
if snapshot.credits.is_none() {
|
|
snapshot.credits = self
|
|
.rate_limit_snapshots_by_limit_id
|
|
.get(&limit_id)
|
|
.and_then(|display| display.credits.as_ref())
|
|
.map(|credits| CreditsSnapshot {
|
|
has_credits: credits.has_credits,
|
|
unlimited: credits.unlimited,
|
|
balance: credits.balance.clone(),
|
|
});
|
|
}
|
|
|
|
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
|
|
|
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
|
if is_codex_limit
|
|
&& let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type
|
|
{
|
|
self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);
|
|
}
|
|
let warnings = if is_codex_limit {
|
|
self.rate_limit_warnings.take_warnings(
|
|
snapshot
|
|
.secondary
|
|
.as_ref()
|
|
.map(|window| f64::from(window.used_percent)),
|
|
snapshot
|
|
.secondary
|
|
.as_ref()
|
|
.and_then(|window| window.window_duration_mins),
|
|
snapshot
|
|
.primary
|
|
.as_ref()
|
|
.map(|window| f64::from(window.used_percent)),
|
|
snapshot
|
|
.primary
|
|
.as_ref()
|
|
.and_then(|window| window.window_duration_mins),
|
|
)
|
|
} else {
|
|
vec![]
|
|
};
|
|
|
|
let high_usage = is_codex_limit
|
|
&& (snapshot
|
|
.secondary
|
|
.as_ref()
|
|
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
|
.unwrap_or(false)
|
|
|| snapshot
|
|
.primary
|
|
.as_ref()
|
|
.map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
|
.unwrap_or(false));
|
|
|
|
let has_workspace_credits = snapshot
|
|
.credits
|
|
.as_ref()
|
|
.map(|credits| credits.has_credits)
|
|
.unwrap_or(false);
|
|
|
|
if high_usage
|
|
&& !has_workspace_credits
|
|
&& !self.rate_limit_switch_prompt_hidden()
|
|
&& self.current_model() != NUDGE_MODEL_SLUG
|
|
&& !matches!(
|
|
self.rate_limit_switch_prompt,
|
|
RateLimitSwitchPromptState::Shown
|
|
)
|
|
{
|
|
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
|
}
|
|
|
|
let display =
|
|
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
|
self.rate_limit_snapshots_by_limit_id
|
|
.insert(limit_id, display);
|
|
|
|
if !warnings.is_empty() {
|
|
for warning in warnings {
|
|
self.add_to_history(history_cell::new_warning_event(warning));
|
|
}
|
|
self.request_redraw();
|
|
}
|
|
} else {
|
|
self.rate_limit_snapshots_by_limit_id.clear();
|
|
self.codex_rate_limit_reached_type = None;
|
|
}
|
|
self.refresh_status_line();
|
|
}
|
|
|
|
pub(super) fn stop_rate_limit_poller(&mut self) {}
|
|
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub(super) fn prefetch_rate_limits(&mut self) {
|
|
self.stop_rate_limit_poller();
|
|
}
|
|
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub(super) fn should_prefetch_rate_limits(&self) -> bool {
|
|
self.config.model_provider.requires_openai_auth && self.has_chatgpt_account
|
|
}
|
|
|
|
fn lower_cost_preset(&self) -> Option<ModelPreset> {
|
|
let models = self.model_catalog.try_list_models().ok()?;
|
|
models
|
|
.iter()
|
|
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
|
|
.cloned()
|
|
}
|
|
|
|
fn rate_limit_switch_prompt_hidden(&self) -> bool {
|
|
self.config
|
|
.notices
|
|
.hide_rate_limit_model_nudge
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {
|
|
if self.rate_limit_switch_prompt_hidden() {
|
|
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
|
return;
|
|
}
|
|
if !matches!(
|
|
self.rate_limit_switch_prompt,
|
|
RateLimitSwitchPromptState::Pending
|
|
) {
|
|
return;
|
|
}
|
|
if let Some(preset) = self.lower_cost_preset() {
|
|
self.open_rate_limit_switch_prompt(preset);
|
|
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown;
|
|
} else {
|
|
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
|
}
|
|
}
|
|
|
|
fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) {
|
|
let switch_model = preset.model;
|
|
let switch_model_for_events = switch_model.clone();
|
|
let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort;
|
|
|
|
let switch_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::CodexOp(AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
/*approval_policy*/ None,
|
|
/*approvals_reviewer*/ None,
|
|
/*permission_profile*/ None,
|
|
/*windows_sandbox_level*/ None,
|
|
Some(switch_model_for_events.clone()),
|
|
Some(Some(default_effort)),
|
|
/*summary*/ None,
|
|
/*service_tier*/ None,
|
|
/*collaboration_mode*/ None,
|
|
/*personality*/ None,
|
|
)));
|
|
tx.send(AppEvent::UpdateModel(switch_model_for_events.clone()));
|
|
tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort)));
|
|
})];
|
|
|
|
let keep_actions: Vec<SelectionAction> = Vec::new();
|
|
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
|
|
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
|
|
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
|
|
})];
|
|
let description = if preset.description.is_empty() {
|
|
Some("Uses fewer credits for upcoming turns.".to_string())
|
|
} else {
|
|
Some(preset.description)
|
|
};
|
|
|
|
let items = vec![
|
|
SelectionItem {
|
|
name: format!("Switch to {switch_model}"),
|
|
description,
|
|
selected_description: None,
|
|
is_current: false,
|
|
actions: switch_actions,
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
SelectionItem {
|
|
name: "Keep current model".to_string(),
|
|
description: None,
|
|
selected_description: None,
|
|
is_current: false,
|
|
actions: keep_actions,
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
SelectionItem {
|
|
name: "Keep current model (never show again)".to_string(),
|
|
description: Some(
|
|
"Hide future rate limit reminders about switching models.".to_string(),
|
|
),
|
|
selected_description: None,
|
|
is_current: false,
|
|
actions: never_actions,
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
|
title: Some("Approaching rate limits".to_string()),
|
|
subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")),
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
items,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
pub(super) fn open_workspace_owner_nudge_prompt(
|
|
&mut self,
|
|
credit_type: AddCreditsNudgeCreditType,
|
|
) {
|
|
if self.add_credits_nudge_email_in_flight.is_some() {
|
|
return;
|
|
}
|
|
|
|
let (title, prompt) = match credit_type {
|
|
AddCreditsNudgeCreditType::Credits => (
|
|
"You've reached your workspace credit limit",
|
|
"Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?",
|
|
),
|
|
AddCreditsNudgeCreditType::UsageLimit => (
|
|
"Usage limit reached",
|
|
"Request a limit increase from your owner to continue using codex. Request increase?",
|
|
),
|
|
};
|
|
let send_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type });
|
|
})];
|
|
let items = vec![
|
|
SelectionItem {
|
|
name: "Yes".to_string(),
|
|
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
|
|
actions: send_actions,
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
SelectionItem {
|
|
name: "No".to_string(),
|
|
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
|
|
is_default: true,
|
|
dismiss_on_select: true,
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
|
title: Some(title.to_string()),
|
|
subtitle: Some(prompt.to_string()),
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
items,
|
|
initial_selected_idx: Some(1),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
pub(crate) fn start_add_credits_nudge_email_request(
|
|
&mut self,
|
|
credit_type: AddCreditsNudgeCreditType,
|
|
) -> bool {
|
|
self.add_credits_nudge_email_in_flight = Some(credit_type);
|
|
true
|
|
}
|
|
|
|
pub(crate) fn finish_add_credits_nudge_email_request(
|
|
&mut self,
|
|
result: Result<AddCreditsNudgeEmailStatus, String>,
|
|
) {
|
|
let credit_type = self
|
|
.add_credits_nudge_email_in_flight
|
|
.take()
|
|
.unwrap_or(AddCreditsNudgeCreditType::Credits);
|
|
let message = match (credit_type, result) {
|
|
(AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
|
|
"Workspace owner notified."
|
|
}
|
|
(
|
|
AddCreditsNudgeCreditType::Credits,
|
|
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
|
) => "Workspace owner was already notified recently.",
|
|
(AddCreditsNudgeCreditType::Credits, Err(_)) => {
|
|
"Could not notify your workspace owner. Please try again."
|
|
}
|
|
(AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
|
|
"Limit increase requested."
|
|
}
|
|
(
|
|
AddCreditsNudgeCreditType::UsageLimit,
|
|
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
|
) => "A limit increase was already requested recently.",
|
|
(AddCreditsNudgeCreditType::UsageLimit, Err(_)) => {
|
|
"Could not request a limit increase. Please try again."
|
|
}
|
|
};
|
|
self.add_to_history(history_cell::new_info_event(
|
|
message.to_string(),
|
|
/*hint*/ None,
|
|
));
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
|
|
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
|
|
if hidden {
|
|
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
|
}
|
|
}
|
|
}
|