use crate::agent::ui::theme::ThemeKey;
use crate::agent::ui::theme::current_theme;
use crate::tui::Component;
use crate::tui::fuzzy::fuzzy_filter;
use crate::tui::keybindings::{
ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP,
get_keybindings,
};
use crossterm::event::{KeyCode, KeyEvent};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AuthType {
OAuth,
ApiKey,
}
impl AuthType {
fn as_str(&self) -> &'static str {
match self {
AuthType::OAuth => "oauth",
AuthType::ApiKey => "api_key",
}
}
}
#[derive(Debug, Clone)]
pub struct AuthSelectorProvider {
pub id: String,
pub name: String,
pub auth_type: AuthType,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectorMode {
Login,
Logout,
}
#[derive(Clone)]
#[allow(dead_code)]
struct ProviderItem {
id: String,
name: String,
auth_type: AuthType,
has_stored: bool,
has_other_auth: bool,
status_label: String,
search_text: String,
}
impl ProviderItem {
fn search_text(&self) -> &str {
&self.search_text
}
}
pub struct ProviderAuthStatus {
pub configured: bool,
pub source: Option<String>,
pub label: Option<String>,
}
pub struct OAuthSelector {
mode: SelectorMode,
items: Vec<ProviderItem>,
filtered_indices: Vec<usize>,
selected_index: usize,
search_query: String,
max_visible: usize,
on_select: Option<Box<dyn FnOnce(String)>>, on_cancel: Option<Box<dyn FnOnce()>>,
}
impl OAuthSelector {
pub fn new(
providers: Vec<AuthSelectorProvider>,
auth_status: impl Fn(&str) -> ProviderAuthStatus,
mode: SelectorMode,
) -> Self {
let items: Vec<ProviderItem> = providers
.into_iter()
.map(|p| {
let status = auth_status(&p.id);
let has_stored =
status.configured && matches!(status.source.as_deref(), Some("stored"));
let has_other_auth = status.configured && !has_stored;
let status_label = if has_stored {
"configured".to_string()
} else if let Some(label) = status.label {
format!("env: {}", label)
} else if status.configured {
"configured".to_string()
} else {
"unconfigured".to_string()
};
let id = p.id;
let name = p.name;
let auth_type = p.auth_type;
let search_text =
format!("{} {} {} {}", id, name, auth_type.as_str(), status_label);
ProviderItem {
id,
name,
auth_type,
has_stored,
has_other_auth,
status_label,
search_text,
}
})
.collect();
let mut sorted = items;
sorted.sort_by(|a, b| {
let a_priority = if a.has_stored {
0
} else if a.has_other_auth {
1
} else {
2
};
let b_priority = if b.has_stored {
0
} else if b.has_other_auth {
1
} else {
2
};
a_priority.cmp(&b_priority).then(a.name.cmp(&b.name))
});
let filtered: Vec<usize> = (0..sorted.len()).collect();
Self {
mode,
items: sorted,
filtered_indices: filtered,
selected_index: 0,
search_query: String::new(),
max_visible: 10,
on_select: None,
on_cancel: None,
}
}
pub fn on_select<F>(&mut self, f: F)
where
F: FnOnce(String) + 'static,
{
self.on_select = Some(Box::new(f));
}
pub fn on_cancel<F>(&mut self, f: F)
where
F: FnOnce() + 'static,
{
self.on_cancel = Some(Box::new(f));
}
fn refresh(&mut self) {
let query = self.search_query.clone();
self.filtered_indices = if query.trim().is_empty() {
(0..self.items.len()).collect()
} else {
fuzzy_filter(&self.items, &query, |item| item.search_text())
};
self.selected_index = self
.selected_index
.min(self.filtered_indices.len().saturating_sub(1));
}
fn get_item(&self, filtered_idx: usize) -> Option<&ProviderItem> {
self.filtered_indices
.get(filtered_idx)
.and_then(|&idx| self.items.get(idx))
}
}
impl Component for OAuthSelector {
fn render(&mut self, width: usize) -> Vec<String> {
use crate::tui::util::truncate_to_width;
let theme = current_theme();
let mut lines: Vec<String> = Vec::new();
lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
lines.push(String::new());
let title = match self.mode {
SelectorMode::Login => "Select provider to configure:",
SelectorMode::Logout => "Select provider to logout:",
};
lines.push(format!(
" {}",
theme.bold(&theme.fg_key(ThemeKey::Accent, title))
));
lines.push(String::new());
let search_value = if self.search_query.is_empty() {
String::new()
} else {
self.search_query.clone()
};
lines.push(format!(" {}{}", theme.dim("Search: "), search_value));
lines.push(String::new());
let count = self.filtered_indices.len();
if count == 0 {
let msg = if self.items.is_empty() {
match self.mode {
SelectorMode::Login => "No providers available",
SelectorMode::Logout => "No providers logged in. Use /login first.",
}
} else {
"No matching providers"
};
lines.push(theme.dim(&format!(" {}", msg)));
} else {
let start = self
.selected_index
.saturating_sub(self.max_visible / 2)
.min(count.saturating_sub(self.max_visible));
let end = (start + self.max_visible).min(count);
for i in start..end {
let item = &self.items[self.filtered_indices[i]];
let is_selected = i == self.selected_index;
let prefix = if is_selected {
theme.fg_key(ThemeKey::Accent, "→ ")
} else {
" ".to_string()
};
let name_text = if is_selected {
theme.fg_key(ThemeKey::Accent, &item.name)
} else {
theme.fg_key(ThemeKey::Text, &item.name)
};
let status = if item.has_stored {
theme.success(" ✓ configured")
} else if item.has_other_auth {
theme.success(&format!(" ✓ {}", item.status_label))
} else {
theme.dim(" • unconfigured")
};
lines.push(truncate_to_width(
&format!("{}{}{}", prefix, name_text, status),
width.saturating_sub(4),
"",
false,
));
}
if count > self.max_visible {
lines.push(theme.dim(&format!(" ({}/{})", self.selected_index + 1, count)));
}
}
lines.push(String::new());
lines.push(format!(
" {}",
theme.dim("Enter: select · Esc: cancel · Type to search")
));
lines.push(String::new());
lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
lines
}
fn handle_input(&mut self, key: &KeyEvent) -> bool {
let kb = get_keybindings();
if kb.matches(key, ACTION_SELECT_UP) {
if self.filtered_indices.is_empty() {
return true;
}
self.selected_index = if self.selected_index == 0 {
self.filtered_indices.len() - 1
} else {
self.selected_index - 1
};
return true;
}
if kb.matches(key, ACTION_SELECT_DOWN) {
if self.filtered_indices.is_empty() {
return true;
}
self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
0
} else {
self.selected_index + 1
};
return true;
}
if kb.matches(key, ACTION_SELECT_CONFIRM) {
let selected_id = self
.get_item(self.selected_index)
.map(|item| item.id.clone());
if let Some(id) = selected_id
&& let Some(cb) = self.on_select.take()
{
cb(id);
}
return true;
}
if kb.matches(key, ACTION_SELECT_CANCEL) {
if let Some(cb) = self.on_cancel.take() {
cb();
}
return false;
}
if key.code == KeyCode::Backspace {
if !self.search_query.is_empty() {
self.search_query.pop();
self.refresh();
}
return true;
}
if let KeyCode::Char(c) = key.code
&& !c.is_control()
{
self.search_query.push(c);
self.refresh();
return true;
}
false
}
}