Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
113 changes: 113 additions & 0 deletions codex-rs/tui/src/chatwidget/auth_profile_popup_order.rs
Original file line number Diff line number Diff line change
@@ -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<f64>,
}

impl AuthProfileUsageStatus {
pub(super) fn unknown() -> Self {
Self {
group: AuthProfileUsageGroup::Active,
remaining_percent: None,
}
}

pub(super) fn for_snapshots(snapshots: &BTreeMap<String, RateLimitSnapshotDisplay>) -> 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<String, RateLimitSnapshotDisplay>,
) -> 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<f64> {
[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<f64>, right: Option<f64>) -> 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()
}
112 changes: 89 additions & 23 deletions codex-rs/tui/src/chatwidget/auth_profile_popups.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -67,13 +73,54 @@ impl ChatWidget {
current: Option<&str>,
selected_idx: Option<usize>,
) -> 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)));
Expand All @@ -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
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -833,23 +916,6 @@ fn auth_profile_usage_snapshots_are_fresh(
})
}

fn usage_snapshot_with_windows(
snapshots: &BTreeMap<String, RateLimitSnapshotDisplay>,
) -> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading