feat(tui/accounts): prioritize chatgpt accounts in login/limits

This commit is contained in:
James Peter
2025-09-27 11:22:05 +10:00
parent e8870075e7
commit 1db6193170
5 changed files with 110 additions and 59 deletions
+44
View File
@@ -0,0 +1,44 @@
use codex_core::auth_accounts::StoredAccount;
use codex_protocol::mcp_protocol::AuthMode;
const KEY_SUFFIX_LEN: usize = 8;
/// Returns a user-facing label for the given account.
/// Prefers stored labels when present, otherwise formats a sensible default.
pub(crate) fn account_display_label(account: &StoredAccount) -> String {
if let Some(label) = account
.label
.as_ref()
.and_then(|value| (!value.trim().is_empty()).then(|| value.trim().to_string()))
{
return label;
}
match account.mode {
AuthMode::ChatGPT => account
.tokens
.as_ref()
.and_then(|tokens| tokens.id_token.email.clone())
.map(|email| format!("ChatGPT ({email})"))
.unwrap_or_else(|| "ChatGPT".to_string()),
AuthMode::ApiKey => account
.openai_api_key
.as_ref()
.map(|key| format!("API key (…{})", key_suffix(key)))
.unwrap_or_else(|| "API key".to_string()),
}
}
/// Returns the fixed-length suffix used when displaying sensitive tokens.
pub(crate) fn key_suffix(text: &str) -> String {
let tail: Vec<char> = text.chars().rev().take(KEY_SUFFIX_LEN).collect();
tail.into_iter().rev().collect()
}
/// Returns an ordering priority for accounts. ChatGPT accounts should appear first.
pub(crate) fn account_mode_priority(mode: AuthMode) -> u8 {
match mode {
AuthMode::ChatGPT => 0,
AuthMode::ApiKey => 1,
}
}
@@ -14,6 +14,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::account_label::{account_display_label, account_mode_priority};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -117,6 +118,11 @@ impl LoginAccountsState {
}
fn reload_accounts(&mut self) {
let previously_selected_id = self
.accounts
.get(self.selected)
.map(|row| row.id.clone());
match auth_accounts::list_accounts(&self.codex_home) {
Ok(raw_accounts) => {
let active_id = auth_accounts::get_active_account_id(&self.codex_home).ok().flatten();
@@ -125,10 +131,33 @@ impl LoginAccountsState {
.into_iter()
.map(|account| AccountRow::from(account, active_id.as_deref()))
.collect();
self.accounts.sort_by(|a, b| {
let priority = account_mode_priority;
let a_priority = priority(a.mode);
let b_priority = priority(b.mode);
a_priority
.cmp(&b_priority)
.then_with(|| a.label.to_ascii_lowercase().cmp(&b.label.to_ascii_lowercase()))
.then_with(|| a.label.cmp(&b.label))
.then_with(|| a.id.cmp(&b.id))
});
let mut selected_idx = previously_selected_id
.and_then(|id| self.accounts.iter().position(|row| row.id == id))
.or_else(|| {
active_id
.as_ref()
.and_then(|id| self.accounts.iter().position(|row| &row.id == id))
});
if self.accounts.is_empty() {
self.selected = 0;
} else if self.selected >= self.accounts.len() {
self.selected = self.accounts.len().saturating_sub(1);
} else {
if selected_idx.is_none() {
selected_idx = Some(0);
}
self.selected = selected_idx.unwrap_or(0).min(self.accounts.len() - 1);
}
}
Err(err) => {
@@ -832,23 +861,12 @@ impl LoginAddAccountState {
impl AccountRow {
fn from(account: StoredAccount, active_id: Option<&str>) -> Self {
let label = match account.mode {
AuthMode::ApiKey => account
.openai_api_key
.as_ref()
.map(|key| format!("API key (…{})", suffix(key)))
.unwrap_or_else(|| "API key".to_string()),
AuthMode::ChatGPT => account
.tokens
.as_ref()
.and_then(|t| t.id_token.email.clone())
.map(|email| format!("ChatGPT ({email})"))
.unwrap_or_else(|| "ChatGPT".to_string()),
};
let id = account.id.clone();
let label = account_display_label(&account);
let mode = account.mode;
let mut detail_parts: Vec<String> = Vec::new();
if let AuthMode::ChatGPT = account.mode {
if let AuthMode::ChatGPT = mode {
if let Some(plan) = account
.tokens
.as_ref()
@@ -868,26 +886,18 @@ impl AccountRow {
Some(detail_parts.join(" • "))
};
let is_active = active_id.is_some_and(|candidate| candidate == id);
Self {
id: account.id.clone(),
id,
label,
detail,
mode: account.mode,
is_active: active_id.is_some_and(|id| id == account.id),
mode,
is_active,
}
}
}
fn suffix(text: &str) -> String {
let tail = text
.char_indices()
.rev()
.take(4)
.map(|(_, c)| c)
.collect::<Vec<_>>();
tail.into_iter().rev().collect()
}
fn format_timestamp(ts: DateTime<Utc>) -> String {
ts.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M")
+23 -25
View File
@@ -173,6 +173,7 @@ fn status_tokens_prefix() -> &'static str {
STATUS_TOKENS_PREFIX
}
use crate::account_label::{account_display_label, account_mode_priority};
use crate::app_event::{
AppEvent,
BackgroundPlacement,
@@ -3604,7 +3605,7 @@ impl ChatWidget<'_> {
.and_then(|id| usage_summary_map.get(id));
let title = account_ref
.map(Self::account_label)
.map(account_display_label)
.or_else(|| active_id.clone())
.unwrap_or_else(|| "Current session".to_string());
let header = Self::account_header_lines(account_ref, snapshot_ref, summary_ref);
@@ -3626,23 +3627,28 @@ impl ChatWidget<'_> {
.filter(|id| !seen_ids.contains(*id))
.cloned()
.collect();
remaining_ids.sort_by(|a, b| {
let a_label = account_map
.get(a)
.map(Self::account_label)
.unwrap_or_else(|| a.clone());
let b_label = account_map
.get(b)
.map(Self::account_label)
.unwrap_or_else(|| b.clone());
let a_cmp = a_label.to_ascii_lowercase();
let b_cmp = b_label.to_ascii_lowercase();
let order = a_cmp.cmp(&b_cmp);
if order == std::cmp::Ordering::Equal {
a_label.cmp(&b_label)
let account_sort_key = |id: &String| {
if let Some(account) = account_map.get(id) {
let label = account_display_label(account);
(
account_mode_priority(account.mode),
label.to_ascii_lowercase(),
label,
)
} else {
order
(u8::MAX, id.to_ascii_lowercase(), id.clone())
}
};
remaining_ids.sort_by(|a, b| {
let (a_priority, a_lower, a_label) = account_sort_key(a);
let (b_priority, b_lower, b_label) = account_sort_key(b);
a_priority
.cmp(&b_priority)
.then_with(|| a_lower.cmp(&b_lower))
.then_with(|| a_label.cmp(&b_label))
.then_with(|| a.cmp(b))
});
for id in remaining_ids {
@@ -3650,7 +3656,7 @@ impl ChatWidget<'_> {
let record = snapshot_map.remove(&id);
let usage_summary = usage_summary_map.remove(&id);
let title = account
.map(Self::account_label)
.map(account_display_label)
.unwrap_or_else(|| id.clone());
match record {
Some(record) => {
@@ -3712,14 +3718,6 @@ impl ChatWidget<'_> {
tabs
}
fn account_label(account: &StoredAccount) -> String {
account
.label
.clone()
.filter(|label| !label.trim().is_empty())
.unwrap_or_else(|| account.id.clone())
}
fn usage_cost_usd_from_totals(totals: &TokenTotals) -> f64 {
let non_cached_input = totals
.input_tokens
+2 -4
View File
@@ -1,3 +1,4 @@
use crate::account_label::key_suffix;
use crate::diff_render::create_diff_summary_with_width;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::sanitize::Mode as SanitizeMode;
@@ -5632,10 +5633,7 @@ pub(crate) fn new_status_output(
.ok()
.and_then(|a| a.openai_api_key)
.or_else(|| std::env::var(OPENAI_API_KEY_ENV_VAR).ok())
.map(|k| {
let n = k.len().saturating_sub(4);
k[n..].to_string()
})
.map(|k| key_suffix(&k))
.unwrap_or_else(|| "????".to_string());
lines.push(Line::from(format!(" • Method: API key (…{suffix})")));
}
+1
View File
@@ -33,6 +33,7 @@ mod app;
mod app_event;
mod app_event_sender;
mod backtrack_helpers;
mod account_label;
mod bottom_pane;
mod chatwidget;
mod citation_regex;