diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ed20236f14..d02985741a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -414,6 +414,7 @@ use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod model_popups; mod notifications; use self::notifications::Notification; +mod auth_profile_popup_order; mod auth_profile_popups; mod permission_popups; mod permissions_menu; diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popup_order.rs b/codex-rs/tui/src/chatwidget/auth_profile_popup_order.rs new file mode 100644 index 0000000000..57bc504785 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/auth_profile_popup_order.rs @@ -0,0 +1,113 @@ +use crate::status::RateLimitSnapshotDisplay; +use codex_login::AuthProfile; +use std::cmp::Ordering; +use std::collections::BTreeMap; + +pub(super) enum AuthProfileSelectionTarget { + Default, + Named(AuthProfile), +} + +pub(super) struct AuthProfileSelectionEntry { + pub(super) target: AuthProfileSelectionTarget, + pub(super) usage_status: AuthProfileUsageStatus, + pub(super) original_index: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AuthProfileUsageGroup { + Active, + Exhausted, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct AuthProfileUsageStatus { + pub(super) group: AuthProfileUsageGroup, + remaining_percent: Option, +} + +impl AuthProfileUsageStatus { + pub(super) fn unknown() -> Self { + Self { + group: AuthProfileUsageGroup::Active, + remaining_percent: None, + } + } + + pub(super) fn for_snapshots(snapshots: &BTreeMap) -> Self { + let Some(snapshot) = usage_snapshot_with_windows(snapshots) else { + return Self::unknown(); + }; + let Some(remaining_percent) = limiting_remaining_percent(snapshot) else { + return Self::unknown(); + }; + let group = if remaining_percent <= 0.0 { + AuthProfileUsageGroup::Exhausted + } else { + AuthProfileUsageGroup::Active + }; + Self { + group, + remaining_percent: Some(remaining_percent), + } + } +} + +pub(super) fn sort_auth_profile_selection_entries(entries: &mut [AuthProfileSelectionEntry]) { + entries.sort_by(|left, right| { + auth_profile_usage_group_rank(left.usage_status.group) + .cmp(&auth_profile_usage_group_rank(right.usage_status.group)) + .then_with(|| { + if left.usage_status.group == AuthProfileUsageGroup::Active { + compare_remaining_percent_desc( + left.usage_status.remaining_percent, + right.usage_status.remaining_percent, + ) + } else { + Ordering::Equal + } + }) + .then_with(|| left.original_index.cmp(&right.original_index)) + }); +} + +pub(super) fn usage_snapshot_with_windows( + snapshots: &BTreeMap, +) -> Option<&RateLimitSnapshotDisplay> { + snapshots + .get("codex") + .filter(|snapshot| usage_snapshot_has_windows(snapshot)) + .or_else(|| { + snapshots + .values() + .find(|snapshot| usage_snapshot_has_windows(snapshot)) + }) +} + +fn limiting_remaining_percent(snapshot: &RateLimitSnapshotDisplay) -> Option { + [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + .map(|window| (100.0 - window.used_percent).clamp(0.0, 100.0)) + .reduce(f64::min) +} + +fn auth_profile_usage_group_rank(group: AuthProfileUsageGroup) -> u8 { + match group { + AuthProfileUsageGroup::Active => 0, + AuthProfileUsageGroup::Exhausted => 1, + } +} + +fn compare_remaining_percent_desc(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => right.total_cmp(&left), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +fn usage_snapshot_has_windows(snapshot: &RateLimitSnapshotDisplay) -> bool { + snapshot.primary.is_some() || snapshot.secondary.is_some() +} diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs index 32307d1ee3..43e5836112 100644 --- a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs +++ b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs @@ -1,6 +1,12 @@ //! Auth profile picker for `ChatWidget`. use super::*; +use crate::chatwidget::auth_profile_popup_order::AuthProfileSelectionEntry; +use crate::chatwidget::auth_profile_popup_order::AuthProfileSelectionTarget; +use crate::chatwidget::auth_profile_popup_order::AuthProfileUsageGroup; +use crate::chatwidget::auth_profile_popup_order::AuthProfileUsageStatus; +use crate::chatwidget::auth_profile_popup_order::sort_auth_profile_selection_entries; +use crate::chatwidget::auth_profile_popup_order::usage_snapshot_with_windows; use crate::status::RATE_LIMIT_STALE_THRESHOLD_MINUTES; use crate::status::RateLimitSnapshotDisplay; use crate::status::RateLimitWindowDisplay; @@ -67,13 +73,54 @@ impl ChatWidget { current: Option<&str>, selected_idx: Option, ) -> SelectionViewParams { - let mut items = Vec::with_capacity(profiles.len() + 2); - items.push(self.default_auth_profile_item(current.is_none())); - items.extend( - profiles - .into_iter() - .map(|profile| self.named_auth_profile_item(profile, current)), + let mut profile_entries = Vec::with_capacity(profiles.len() + 1); + profile_entries.push(AuthProfileSelectionEntry { + target: AuthProfileSelectionTarget::Default, + usage_status: self.auth_profile_usage_status(/*profile*/ None), + original_index: 0, + }); + for (idx, profile) in profiles.into_iter().enumerate() { + let usage_status = self.auth_profile_usage_status(Some(profile.name.as_str())); + profile_entries.push(AuthProfileSelectionEntry { + target: AuthProfileSelectionTarget::Named(profile), + usage_status, + original_index: idx + 1, + }); + } + sort_auth_profile_selection_entries(&mut profile_entries); + + let has_active_profiles = profile_entries + .iter() + .any(|entry| entry.usage_status.group == AuthProfileUsageGroup::Active); + let has_exhausted_profiles = profile_entries + .iter() + .any(|entry| entry.usage_status.group == AuthProfileUsageGroup::Exhausted); + + let mut items = Vec::with_capacity( + profile_entries + .len() + .saturating_add(1) + .saturating_add(usize::from(has_active_profiles)) + .saturating_add(usize::from(has_exhausted_profiles)), ); + if has_active_profiles { + items.push(auth_profile_group_item("Active profiles")); + items.extend( + profile_entries + .iter() + .filter(|entry| entry.usage_status.group == AuthProfileUsageGroup::Active) + .map(|entry| self.profile_selection_item(entry, current)), + ); + } + if has_exhausted_profiles { + items.push(auth_profile_group_item("Exhausted profiles")); + items.extend( + profile_entries + .iter() + .filter(|entry| entry.usage_status.group == AuthProfileUsageGroup::Exhausted) + .map(|entry| self.profile_selection_item(entry, current)), + ); + } items.push(self.new_auth_profile_item()); let initial_selected_idx = selected_idx.map(|idx| idx.min(items.len().saturating_sub(1))); @@ -92,6 +139,21 @@ impl ChatWidget { } } + fn profile_selection_item( + &self, + entry: &AuthProfileSelectionEntry, + current: Option<&str>, + ) -> SelectionItem { + match &entry.target { + AuthProfileSelectionTarget::Default => { + self.default_auth_profile_item(current.is_none()) + } + AuthProfileSelectionTarget::Named(profile) => { + self.named_auth_profile_item(profile.clone(), current) + } + } + } + pub(crate) fn refresh_profile_popup_if_active(&mut self) -> bool { let Some(selected_idx) = self .bottom_pane @@ -662,6 +724,19 @@ impl ChatWidget { compact_usage_hint_for_snapshots(snapshots) } + fn auth_profile_usage_status(&self, profile: Option<&str>) -> AuthProfileUsageStatus { + let Some(snapshots) = self.auth_profile_usage_snapshots(profile) else { + return AuthProfileUsageStatus::unknown(); + }; + if !auth_profile_usage_snapshots_are_fresh( + snapshots, + Duration::from_secs((RATE_LIMIT_STALE_THRESHOLD_MINUTES as u64).saturating_mul(60)), + ) { + return AuthProfileUsageStatus::unknown(); + } + AuthProfileUsageStatus::for_snapshots(snapshots) + } + fn auth_profile_usage_snapshots( &self, profile: Option<&str>, @@ -753,6 +828,14 @@ fn auth_profile_auth_mode_label(auth_mode: codex_app_server_protocol::AuthMode) } } +fn auth_profile_group_item(name: &str) -> SelectionItem { + SelectionItem { + name: name.to_string(), + is_disabled: true, + ..Default::default() + } +} + fn profile_supports_usage(profile: &AuthProfile) -> bool { profile.subscription_provider == AuthProfileSubscriptionProvider::ChatGpt && auth_mode_supports_usage(profile.auth_mode) @@ -833,23 +916,6 @@ fn auth_profile_usage_snapshots_are_fresh( }) } -fn usage_snapshot_with_windows( - snapshots: &BTreeMap, -) -> Option<&RateLimitSnapshotDisplay> { - snapshots - .get("codex") - .filter(|snapshot| usage_snapshot_has_windows(snapshot)) - .or_else(|| { - snapshots - .values() - .find(|snapshot| usage_snapshot_has_windows(snapshot)) - }) -} - -fn usage_snapshot_has_windows(snapshot: &RateLimitSnapshotDisplay) -> bool { - snapshot.primary.is_some() || snapshot.secondary.is_some() -} - fn compact_usage_hint_for_window(window: &RateLimitWindowDisplay, is_secondary: bool) -> String { let label = limit_label_for_window(window.window_minutes, is_secondary); let remaining = (100.0 - window.used_percent).clamp(0.0, 100.0); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup.snap index 61db8b1847..8040f380a5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup.snap @@ -5,6 +5,7 @@ expression: popup Select Profile Switch auth for this session. + Active profiles › 1. default (current) usage unknown 2. personal ChatGPT Pro / el@elyratelier.com 3. work ChatGPT API key diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_groups_by_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_groups_by_usage.snap new file mode 100644 index 0000000000..9e927bb500 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_groups_by_usage.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Select Profile + Switch auth for this session. + + Active profiles + 1. beta-high ChatGPT Pro / high@example.com + 2. alpha-low ChatGPT Pro / low@example.com +› 3. default (current) usage unknown + Exhausted profiles + 4. zeta-spent ChatGPT Pro / spent@example.com + 5. Log in new profile Create a saved auth profile + + Enter switch / l relogin / r rename / d delete / s settings / [ up / ] down + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_profile_actions.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_profile_actions.snap index 4f9984cd89..653ba04a8e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_profile_actions.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_profile_actions.snap @@ -5,6 +5,7 @@ expression: popup Select Profile Switch auth for this session. + Active profiles 1. default (current) Root login › 2. personal usage unknown 3. work ChatGPT API key diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_usage_hints.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_usage_hints.snap index 6f17eef47c..84cd672023 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_usage_hints.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_selection_popup_usage_hints.snap @@ -5,9 +5,10 @@ expression: popup Select Profile Switch auth for this session. - 1. default Root login -› 2. personal stale weekly 60% left, 5h 30% left - 3. work (current) ChatGPT API key + Active profiles + 1. work (current) ChatGPT API key + 2. default Root login +› 3. personal stale weekly 60% left, 5h 30% left 4. Log in new profile Create a saved auth profile Enter switch / l relogin / r rename / d delete / s settings / [ up / ] down diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ba689f917d..ba605c26b3 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3211,7 +3211,8 @@ async fn profile_selection_popup_shows_usage_hints() { .insert(Some("personal".to_string()), stale_snapshots); chat.open_profile_popup(); - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); let popup = render_bottom_popup(&chat, /*width*/ 120); assert_chatwidget_snapshot!("profile_selection_popup_usage_hints", popup); @@ -3225,6 +3226,73 @@ async fn profile_selection_popup_shows_usage_hints() { assert!(popup.contains("Enter switch / l relogin / r rename")); } +#[tokio::test] +async fn profile_selection_popup_groups_by_usage_and_keeps_exhausted_selectable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + save_popup_chatgpt_auth_profile(&chat, "alpha-low", "low@example.com"); + save_popup_chatgpt_auth_profile(&chat, "zeta-spent", "spent@example.com"); + save_popup_chatgpt_auth_profile(&chat, "beta-high", "high@example.com"); + while rx.try_recv().is_ok() {} + + chat.on_auth_profile_rate_limit_snapshots( + Some("alpha-low".to_string()), + vec![profile_usage_snapshot( + /*secondary_used_percent*/ 70, /*primary_used_percent*/ 60, + )], + ); + chat.on_auth_profile_rate_limit_snapshots( + Some("zeta-spent".to_string()), + vec![profile_usage_snapshot( + /*secondary_used_percent*/ 100, /*primary_used_percent*/ 10, + )], + ); + chat.on_auth_profile_rate_limit_snapshots( + Some("beta-high".to_string()), + vec![profile_usage_snapshot( + /*secondary_used_percent*/ 10, /*primary_used_percent*/ 5, + )], + ); + + chat.open_profile_popup(); + drain_profile_usage_refresh_events(&mut rx); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!("profile_selection_popup_groups_by_usage", popup); + assert!( + popup.find("Active profiles").expect("active group") + < popup.find("beta-high").expect("high row"), + "expected active group before high profile:\n{popup}" + ); + assert!( + popup.find("beta-high").expect("high row") < popup.find("alpha-low").expect("low row"), + "expected highest remaining profile first:\n{popup}" + ); + assert!( + popup.find("alpha-low").expect("low row") < popup.find("default").expect("default row"), + "expected known active profiles before unknown usage:\n{popup}" + ); + assert!( + popup.find("Exhausted profiles").expect("exhausted group") + < popup.find("zeta-spent").expect("spent row"), + "expected exhausted group before spent profile:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SwitchAuthProfile { + profile, + reason, + resume_queued_input, + .. + }) if profile.as_deref() == Some("zeta-spent") + && reason == crate::app_event::AuthProfileSwitchReason::Manual + && !resume_queued_input + ); +} + #[tokio::test] async fn profile_popup_requests_usage_heartbeat_when_selected_usage_is_missing() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;