use anyhow::Result;
use opencode_provider_manager::{app, config_core, discovery};
use crate::event::AppEvent;
use crate::ui;
use app::state::AppState;
pub struct App {
pub mode: AppMode,
pub should_quit: bool,
pub selected_provider: Option<String>,
pub selected_index: usize,
pub error_message: Option<String>,
pub discovered_models: Vec<discovery::DiscoveredModel>,
pub models_loading: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppMode {
MergedView,
SplitView,
ProviderList,
AuthStatus,
ModelSelector,
ConfigDetail,
Help,
ConfirmDelete(String),
ConfirmRefresh,
AddProvider(AddProviderForm),
EditProvider(EditProviderForm),
}
pub const KNOWN_SDKS: &[&str] = &[
"openai",
"@anthropic-ai/sdk",
"@google/generative-ai",
"@mistralai/sdk",
"@aws-sdk/client-bedrock-runtime",
"@azure/openai",
"ollama",
"Custom...",
];
pub const BUILTIN_PROVIDERS: &[(&str, &str, &str)] = &[
("openai", "OpenAI", "openai"),
("anthropic", "Anthropic", "@anthropic-ai/sdk"),
("google", "Google AI", "@google/generative-ai"),
("groq", "Groq", "openai"),
("mistral", "Mistral AI", "@mistralai/sdk"),
("xai", "xAI (Grok)", "openai"),
("deepseek", "DeepSeek", "openai"),
("together", "Together AI", "openai"),
("ollama", "Ollama", "ollama"),
(
"aws-bedrock",
"AWS Bedrock",
"@aws-sdk/client-bedrock-runtime",
),
("azure-openai", "Azure OpenAI", "@azure/openai"),
];
pub fn all_provider_ids(state: &AppState) -> Vec<(String, bool)> {
let configured = state.provider_ids();
let mut result: Vec<(String, bool)> = configured.into_iter().map(|id| (id, false)).collect();
for (id, _, _) in BUILTIN_PROVIDERS {
if !result.iter().any(|(pid, _)| pid == id) {
result.push((id.to_string(), true));
}
}
result
}
pub fn builtin_provider_config(id: &str) -> Option<config_core::ProviderConfig> {
BUILTIN_PROVIDERS
.iter()
.find(|(bid, _, _)| *bid == id)
.map(|(_, name, npm)| config_core::ProviderConfig {
name: Some(name.to_string()),
npm: Some(npm.to_string()),
..Default::default()
})
}
pub fn copy_source_list(state: &AppState) -> Vec<(String, String)> {
let configured = state.provider_ids();
let mut result: Vec<(String, String)> = Vec::new();
for id in &configured {
let name = state
.get_provider(id)
.and_then(|p| p.name.clone())
.unwrap_or_else(|| id.clone());
result.push((id.clone(), name));
}
for (id, display_name, _) in BUILTIN_PROVIDERS {
if !configured.iter().any(|cid| cid == id) {
result.push((id.to_string(), display_name.to_string()));
}
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdkSelectState {
pub highlight: usize,
pub custom_mode: bool,
pub custom_text: String,
}
impl SdkSelectState {
pub fn new() -> Self {
Self {
highlight: 0,
custom_mode: false,
custom_text: String::new(),
}
}
pub fn value(&self) -> String {
if self.custom_mode {
self.custom_text.clone()
} else {
KNOWN_SDKS[self.highlight].to_string()
}
}
pub fn from_value(value: &str) -> Self {
if let Some(idx) = KNOWN_SDKS.iter().position(|&s| s == value) {
Self {
highlight: idx,
custom_mode: false,
custom_text: String::new(),
}
} else {
Self {
highlight: KNOWN_SDKS.len() - 1, custom_mode: true,
custom_text: value.to_string(),
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddProviderForm {
pub focus: usize,
pub id: String,
pub name: String,
pub sdk: SdkSelectState,
pub base_url: String,
pub show_copy_list: bool,
pub copy_highlight: usize,
}
impl AddProviderForm {
pub fn new() -> Self {
Self {
focus: 0,
id: String::new(),
name: String::new(),
sdk: SdkSelectState::new(),
base_url: String::new(),
show_copy_list: false,
copy_highlight: 0,
}
}
pub fn field_labels() -> [&'static str; 4] {
[
"Provider ID",
"Display Name",
"SDK Package (↑↓ select, Enter confirm)",
"Base URL (optional)",
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditProviderForm {
pub provider_id: String,
pub focus: usize,
pub name: String,
pub sdk: SdkSelectState,
pub base_url: String,
}
impl EditProviderForm {
pub fn field_labels() -> [&'static str; 3] {
[
"Display Name",
"SDK Package (↑↓ select, Enter confirm)",
"Base URL",
]
}
}
impl App {
pub fn new() -> Self {
Self {
mode: AppMode::ProviderList,
should_quit: false,
selected_provider: None,
selected_index: 0,
error_message: None,
discovered_models: Vec::new(),
models_loading: false,
}
}
pub fn on_event(&mut self, event: AppEvent) {
match event {
AppEvent::Quit => self.should_quit = true,
AppEvent::SwitchMode(mode) => {
self.mode = mode;
self.selected_index = 0;
self.selected_provider = None;
}
AppEvent::SelectProvider(id) => {
self.selected_provider = Some(id);
}
AppEvent::SelectIndex(idx) => {
self.selected_index = idx;
}
AppEvent::Error(msg) => {
self.error_message = Some(msg);
}
AppEvent::ClearError => {
self.error_message = None;
}
_ => {}
}
}
pub fn render(&self, frame: &mut ratatui::Frame, state: &AppState) {
match &self.mode {
AppMode::MergedView => ui::render_merged_view(frame, state, self),
AppMode::SplitView => ui::render_split_view(frame, state, self),
AppMode::ProviderList => ui::render_provider_list(frame, state, self),
AppMode::AuthStatus => ui::render_auth_status(frame, state, self),
AppMode::ModelSelector => ui::render_model_selector(frame, state, self),
AppMode::ConfigDetail => ui::render_config_detail(frame, state, self),
AppMode::Help => ui::render_help(frame),
AppMode::ConfirmDelete(provider_id) => {
ui::render_provider_list(frame, state, self);
ui::render_confirm_delete(frame, provider_id);
}
AppMode::ConfirmRefresh => {
ui::render_provider_list(frame, state, self);
ui::render_confirm_refresh(frame);
}
AppMode::AddProvider(form) => {
ui::render_add_provider(frame, form, state);
}
AppMode::EditProvider(form) => {
ui::render_edit_provider(frame, state, form);
}
}
}
}
enum AsyncAction {
FetchModels {
provider_id: String,
force_refresh: bool,
},
None,
}
pub async fn run(
mut terminal: ratatui::DefaultTerminal,
mut state: AppState,
split: bool,
) -> Result<()> {
let mut app = App::new();
if split {
app.mode = AppMode::SplitView;
}
loop {
if app.should_quit {
break;
}
terminal.draw(|frame| app.render(frame, &state))?;
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
if key.kind == crossterm::event::KeyEventKind::Press {
let action = handle_key_event(key, &mut app, &mut state);
match action {
AsyncAction::FetchModels {
provider_id,
force_refresh,
} => {
app.models_loading = true;
terminal.draw(|frame| app.render(frame, &state))?;
let client = discovery::models_dev::ModelsDevClient::new();
match client
.fetch_provider_models_cached(&provider_id, force_refresh)
.await
{
Ok(models) => {
app.discovered_models = models;
}
Err(e) => {
app.error_message =
Some(format!("Failed to fetch models: {e}"));
}
}
app.models_loading = false;
}
AsyncAction::None => {}
}
}
}
}
}
Ok(())
}
fn handle_key_event(
key: crossterm::event::KeyEvent,
app: &mut App,
state: &mut AppState,
) -> AsyncAction {
use crossterm::event::KeyCode;
let had_error = app.error_message.is_some();
if had_error {
app.error_message = None;
}
if app.mode == AppMode::ConfirmRefresh {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Err(e) = state.load_configs() {
app.error_message = Some(format!("Refresh failed: {e}"));
}
app.mode = AppMode::ProviderList;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
_ => {}
}
return AsyncAction::None;
}
if let AppMode::EditProvider(ref mut form) = app.mode {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
KeyCode::Tab => {
form.focus = (form.focus + 1) % EditProviderForm::field_labels().len();
}
KeyCode::BackTab => {
form.focus = (form.focus + EditProviderForm::field_labels().len() - 1)
% EditProviderForm::field_labels().len();
}
KeyCode::Enter => {
if form.focus == 1
&& !form.sdk.custom_mode
&& form.sdk.highlight == KNOWN_SDKS.len() - 1
{
form.sdk.custom_mode = true;
} else if form.focus == 1 && !form.sdk.custom_mode {
} else {
let pid = form.provider_id.clone();
let name_val = form.name.trim().to_string();
let npm_val = form.sdk.value();
let base_url_val = form.base_url.trim().to_string();
let edit_result = (|| -> Result<(), app::error::AppError> {
if !name_val.is_empty() {
state.edit_provider_field(
&pid,
"name",
serde_json::Value::String(name_val),
state.edit_layer,
)?;
}
if !npm_val.is_empty() {
state.edit_provider_field(
&pid,
"npm",
serde_json::Value::String(npm_val),
state.edit_layer,
)?;
}
if !base_url_val.is_empty() {
state.edit_provider_field(
&pid,
"baseURL",
serde_json::Value::String(base_url_val),
state.edit_layer,
)?;
}
Ok(())
})();
match edit_result {
Ok(()) => app.mode = AppMode::ProviderList,
Err(e) => app.error_message = Some(format!("Edit failed: {e}")),
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
if form.focus == 1 && !form.sdk.custom_mode && form.sdk.highlight > 0 {
form.sdk.highlight -= 1;
} else if form.focus == 0 {
}
}
KeyCode::Down | KeyCode::Char('j')
if form.focus == 1
&& !form.sdk.custom_mode
&& form.sdk.highlight < KNOWN_SDKS.len() - 1 =>
{
form.sdk.highlight += 1;
}
KeyCode::Backspace => match form.focus {
0 => {
form.name.pop();
}
1 if form.sdk.custom_mode => {
form.sdk.custom_text.pop();
}
2 => {
form.base_url.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => form.name.push(c),
1 if form.sdk.custom_mode => form.sdk.custom_text.push(c),
2 => form.base_url.push(c),
_ => {}
},
_ => {}
}
return AsyncAction::None;
}
if let AppMode::ConfirmDelete(ref provider_id) = app.mode {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let id = provider_id.clone();
if let Err(e) = state.remove_provider(&id, state.edit_layer) {
app.error_message = Some(format!("Failed to remove provider: {e}"));
}
app.mode = AppMode::ProviderList;
app.selected_provider = None;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
_ => {}
}
return AsyncAction::None;
}
if let AppMode::AddProvider(ref mut form) = app.mode {
if form.show_copy_list {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
KeyCode::Char('c') => {
if !form.show_copy_list && !copy_source_list(state).is_empty() {
form.show_copy_list = true;
form.copy_highlight = 0;
} else {
form.show_copy_list = false;
}
}
KeyCode::Up | KeyCode::Char('k') if form.copy_highlight > 0 => {
form.copy_highlight -= 1;
}
KeyCode::Down | KeyCode::Char('j') => {
let sources = copy_source_list(state);
if form.copy_highlight + 1 < sources.len() {
form.copy_highlight += 1;
}
}
KeyCode::Enter => {
let sources = copy_source_list(state);
if let Some((id, _name)) = sources.get(form.copy_highlight) {
let pc = state
.get_provider(id)
.cloned()
.or_else(|| builtin_provider_config(id));
if let Some(provider) = pc {
form.name = provider.name.unwrap_or_default();
let npm_val = provider.npm.unwrap_or_default();
form.sdk = SdkSelectState::from_value(&npm_val);
form.base_url = provider
.options
.as_ref()
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if form.id.is_empty() {
form.id = format!("{id}-copy");
}
}
}
form.show_copy_list = false;
}
_ => {}
}
return AsyncAction::None;
}
match key.code {
KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if !form.show_copy_list && !copy_source_list(state).is_empty() {
form.show_copy_list = true;
form.copy_highlight = 0;
} else {
form.show_copy_list = false;
}
}
KeyCode::Tab => {
form.focus = (form.focus + 1) % AddProviderForm::field_labels().len();
}
KeyCode::BackTab => {
form.focus = (form.focus + AddProviderForm::field_labels().len() - 1)
% AddProviderForm::field_labels().len();
}
KeyCode::Up | KeyCode::Char('k')
if form.focus == 2 && !form.sdk.custom_mode && form.sdk.highlight > 0 =>
{
form.sdk.highlight -= 1;
}
KeyCode::Down | KeyCode::Char('j')
if form.focus == 2
&& !form.sdk.custom_mode
&& form.sdk.highlight < KNOWN_SDKS.len() - 1 =>
{
form.sdk.highlight += 1;
}
KeyCode::Enter => {
if form.focus == 2
&& !form.sdk.custom_mode
&& form.sdk.highlight == KNOWN_SDKS.len() - 1
{
form.sdk.custom_mode = true;
return AsyncAction::None;
}
let id = form.id.trim().to_string();
let name_val = form.name.trim().to_string();
let npm_val = form.sdk.value();
let base_url_val = form.base_url.trim().to_string();
if id.is_empty() {
app.error_message = Some("Provider ID cannot be empty".to_string());
return AsyncAction::None;
}
let mut options = std::collections::HashMap::new();
if !base_url_val.is_empty() {
options.insert(
"baseURL".to_string(),
serde_json::Value::String(base_url_val),
);
}
let provider_config = config_core::ProviderConfig {
name: if name_val.is_empty() {
None
} else {
Some(name_val)
},
npm: if npm_val.is_empty() || npm_val == "Custom..." {
None
} else {
Some(npm_val)
},
options: if options.is_empty() {
None
} else {
Some(options)
},
models: None,
disabled: None,
extra: Default::default(),
};
if let Err(e) = state.add_provider(id, provider_config, state.edit_layer) {
app.error_message = Some(format!("Failed to add provider: {e}"));
}
app.mode = AppMode::ProviderList;
}
KeyCode::Backspace => match form.focus {
0 => {
form.id.pop();
}
1 => {
form.name.pop();
}
2 if form.sdk.custom_mode => {
form.sdk.custom_text.pop();
}
3 => {
form.base_url.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => form.id.push(c),
1 => form.name.push(c),
2 if form.sdk.custom_mode => form.sdk.custom_text.push(c),
3 => form.base_url.push(c),
_ => {}
},
_ => {}
}
return AsyncAction::None;
}
let mut async_action = AsyncAction::None;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
if app.mode == AppMode::Help {
app.on_event(AppEvent::SwitchMode(AppMode::ProviderList));
} else {
app.on_event(AppEvent::Quit);
}
}
KeyCode::Char('?') => {
if app.mode == AppMode::Help {
app.on_event(AppEvent::SwitchMode(AppMode::ProviderList));
} else {
app.on_event(AppEvent::SwitchMode(AppMode::Help));
}
}
KeyCode::Char('1') => app.on_event(AppEvent::SwitchMode(AppMode::MergedView)),
KeyCode::Char('2') => app.on_event(AppEvent::SwitchMode(AppMode::SplitView)),
KeyCode::Char('p') => app.on_event(AppEvent::SwitchMode(AppMode::ProviderList)),
KeyCode::Char('a') => app.on_event(AppEvent::SwitchMode(AppMode::AuthStatus)),
KeyCode::Char('m') => {
app.mode = AppMode::ModelSelector;
app.selected_index = 0;
app.discovered_models.clear();
let provider_ids = state.provider_ids();
if let Some(provider_id) = provider_ids.get(app.selected_index) {
app.selected_provider = Some(provider_id.clone());
async_action = AsyncAction::FetchModels {
provider_id: provider_id.clone(),
force_refresh: false,
};
}
}
KeyCode::Char('c') => app.on_event(AppEvent::SwitchMode(AppMode::ConfigDetail)),
KeyCode::Char('s') => {
if let Err(e) = state.save(state.edit_layer) {
app.error_message = Some(format!("Save failed: {e}"));
}
}
KeyCode::Char('r') => {
if app.mode == AppMode::ModelSelector {
if let Some(ref provider_id) = app.selected_provider {
app.discovered_models.clear();
async_action = AsyncAction::FetchModels {
provider_id: provider_id.clone(),
force_refresh: true,
};
}
} else if state.dirty {
app.mode = AppMode::ConfirmRefresh;
} else if let Err(e) = state.load_configs() {
app.error_message = Some(format!("Refresh failed: {e}"));
}
}
KeyCode::Char('d') => {
if app.mode == AppMode::ProviderList
&& let Some(provider_id) = state.provider_ids().get(app.selected_index)
{
app.mode = AppMode::ConfirmDelete(provider_id.clone());
}
}
KeyCode::Char('n') if app.mode == AppMode::ProviderList => {
app.mode = AppMode::AddProvider(AddProviderForm::new());
}
KeyCode::Up | KeyCode::Char('k') if app.selected_index > 0 => {
app.on_event(AppEvent::SelectIndex(app.selected_index - 1));
}
KeyCode::Down | KeyCode::Char('j') => {
app.on_event(AppEvent::SelectIndex(app.selected_index.saturating_add(1)));
}
KeyCode::Enter => {
if app.mode == AppMode::ProviderList {
let all_ids = all_provider_ids(state);
if let Some((provider_id, is_builtin)) = all_ids.get(app.selected_index) {
if *is_builtin {
if let Some(pc) = builtin_provider_config(provider_id) {
if let Err(e) =
state.add_provider(provider_id.clone(), pc, state.edit_layer)
{
app.error_message = Some(format!("Failed to add provider: {e}"));
return async_action;
}
}
}
let provider = state.get_provider(provider_id);
let npm_val = provider.and_then(|p| p.npm.clone()).unwrap_or_default();
let form = EditProviderForm {
provider_id: provider_id.clone(),
focus: 0,
name: provider.and_then(|p| p.name.clone()).unwrap_or_default(),
sdk: SdkSelectState::from_value(&npm_val),
base_url: provider
.and_then(|p| p.options.as_ref())
.and_then(|opts| opts.get("baseURL"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
};
app.mode = AppMode::EditProvider(form);
}
}
if app.mode == AppMode::ModelSelector {
if let Some(ref provider_id) = app.selected_provider {
if let Some(model) = app.discovered_models.get(app.selected_index) {
let model_config = config_core::ModelConfig::default();
if let Err(e) = state.add_model(
provider_id,
model.id.clone(),
model_config,
state.edit_layer,
) {
app.error_message = Some(format!("Failed to add model: {e}"));
}
}
}
}
}
_ => {}
}
async_action
}
#[cfg(test)]
mod tests {
use super::*;
use app::state::AppState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn test_app_with_providers() -> (App, AppState) {
let mut state = AppState::new().unwrap();
let mut providers = std::collections::HashMap::new();
providers.insert(
"openai".to_string(),
config_core::ProviderConfig {
name: Some("OpenAI".to_string()),
npm: Some("openai".to_string()),
options: None,
models: Some({
let mut m = std::collections::HashMap::new();
m.insert("gpt-4o".to_string(), config_core::ModelConfig::default());
m
}),
disabled: None,
extra: Default::default(),
},
);
providers.insert(
"anthropic".to_string(),
config_core::ProviderConfig {
name: Some("Anthropic".to_string()),
npm: Some("@anthropic-ai/sdk".to_string()),
options: Some({
let mut o = std::collections::HashMap::new();
o.insert(
"baseURL".to_string(),
serde_json::Value::String("https://api.anthropic.com".to_string()),
);
o
}),
models: None,
disabled: None,
extra: Default::default(),
},
);
state.merged_config.provider = Some(providers);
let app = App::new();
(app, state)
}
fn provider_index(state: &AppState, target_id: &str) -> usize {
state
.provider_ids()
.iter()
.position(|id| id == target_id)
.unwrap_or(0)
}
#[test]
fn test_app_new_defaults() {
let app = App::new();
assert_eq!(app.mode, AppMode::ProviderList);
assert!(!app.should_quit);
assert!(app.selected_provider.is_none());
assert_eq!(app.selected_index, 0);
assert!(app.error_message.is_none());
assert!(app.discovered_models.is_empty());
assert!(!app.models_loading);
}
#[test]
fn test_switch_to_merged_view() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('1')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::MergedView);
}
#[test]
fn test_switch_to_split_view() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('2')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::SplitView);
}
#[test]
fn test_switch_to_auth_status() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('a')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::AuthStatus);
}
#[test]
fn test_switch_to_config_detail() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('c')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ConfigDetail);
}
#[test]
fn test_help_toggle() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('?')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::Help);
handle_key_event(key(KeyCode::Char('?')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_quit_from_provider_list() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('q')), &mut app, &mut state);
assert!(app.should_quit);
}
#[test]
fn test_esc_from_provider_list_quits() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert!(app.should_quit);
}
#[test]
fn test_esc_from_help_goes_back() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('?')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::Help);
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
assert!(!app.should_quit);
}
#[test]
fn test_navigate_down() {
let (mut app, mut state) = test_app_with_providers();
assert_eq!(app.selected_index, 0);
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
assert_eq!(app.selected_index, 1);
}
#[test]
fn test_navigate_up() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
assert_eq!(app.selected_index, 1);
handle_key_event(key(KeyCode::Up), &mut app, &mut state);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_navigate_j_k() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('j')), &mut app, &mut state);
assert_eq!(app.selected_index, 1);
handle_key_event(key(KeyCode::Char('k')), &mut app, &mut state);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_navigate_up_at_zero_does_nothing() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Up), &mut app, &mut state);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_error_cleared_on_next_key() {
let (mut app, mut state) = test_app_with_providers();
app.error_message = Some("test error".to_string());
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
assert!(app.error_message.is_none());
}
#[test]
fn test_add_provider_opens() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
assert!(matches!(app.mode, AppMode::AddProvider(_)));
}
#[test]
fn test_add_provider_type_fields() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
for c in "test-provider".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.id, "test-provider");
assert_eq!(form.focus, 0);
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 1);
}
for c in "Test Provider".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.name, "Test Provider");
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 2);
}
for _ in 0..KNOWN_SDKS.len() - 1 {
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert!(form.sdk.custom_mode);
}
for c in "@test/sdk".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.sdk.custom_text, "@test/sdk");
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 3);
}
for c in "https://api.test.com".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.base_url, "https://api.test.com");
}
}
#[test]
fn test_add_provider_tab_cycles() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
for _ in 0..4 {
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 0);
}
}
#[test]
fn test_add_provider_backspace() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('a')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('b')), &mut app, &mut state);
handle_key_event(key(KeyCode::Backspace), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.id, "a");
}
}
#[test]
fn test_add_provider_empty_id_shows_error() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert!(app.error_message.is_some());
assert!(app.error_message.unwrap().contains("cannot be empty"));
}
#[test]
fn test_add_provider_esc_cancels() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_add_provider_submit_creates_provider() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
for c in "groq".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
for c in "Groq".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
assert!(state.dirty);
let ids = state.provider_ids();
assert!(ids.contains(&"groq".to_string()));
let provider = state.get_provider("groq").unwrap();
assert_eq!(provider.name.as_deref(), Some("Groq"));
}
#[test]
fn test_enter_opens_edit_provider() {
let (mut app, mut state) = test_app_with_providers();
let idx = provider_index(&state, "openai");
app.selected_index = idx;
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert!(matches!(app.mode, AppMode::EditProvider(_)));
if let AppMode::EditProvider(ref form) = app.mode {
assert_eq!(form.provider_id, "openai");
assert_eq!(form.name, "OpenAI");
assert_eq!(form.sdk.highlight, 0); assert!(!form.sdk.custom_mode);
assert_eq!(form.base_url, "");
}
}
#[test]
fn test_edit_provider_esc_cancels() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_edit_provider_edit_name() {
let (mut app, mut state) = test_app_with_providers();
state.project_config = Some(state.merged_config.clone());
let idx = provider_index(&state, "openai");
app.selected_index = idx;
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
if let AppMode::EditProvider(ref form) = app.mode {
assert_eq!(form.name, "OpenAI");
}
for _ in 0.."OpenAI".len() {
handle_key_event(key(KeyCode::Backspace), &mut app, &mut state);
}
for c in "NewOpenAI".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::EditProvider(ref form) = app.mode {
assert_eq!(form.name, "NewOpenAI");
}
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
let provider = state.get_provider("openai").unwrap();
assert_eq!(provider.name.as_deref(), Some("NewOpenAI"));
}
#[test]
fn test_edit_provider_tab_cycles() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
for _ in 0..EditProviderForm::field_labels().len() {
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
}
if let AppMode::EditProvider(ref form) = app.mode {
assert_eq!(form.focus, 0);
}
}
#[test]
fn test_edit_provider_shows_base_url() {
let (mut app, mut state) = test_app_with_providers();
let idx = provider_index(&state, "anthropic");
app.selected_index = idx;
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
if let AppMode::EditProvider(ref form) = app.mode {
assert_eq!(form.provider_id, "anthropic");
assert_eq!(form.base_url, "https://api.anthropic.com");
}
}
#[test]
fn test_delete_opens_confirm() {
let (mut app, mut state) = test_app_with_providers();
let idx = provider_index(&state, "openai");
app.selected_index = idx;
handle_key_event(key(KeyCode::Char('d')), &mut app, &mut state);
assert!(matches!(app.mode, AppMode::ConfirmDelete(_)));
if let AppMode::ConfirmDelete(ref id) = app.mode {
assert_eq!(id, "openai");
}
}
#[test]
fn test_delete_confirm_yes_removes() {
let (mut app, mut state) = test_app_with_providers();
let idx = provider_index(&state, "openai");
app.selected_index = idx;
handle_key_event(key(KeyCode::Char('d')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('y')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
let ids = state.provider_ids();
assert!(!ids.contains(&"openai".to_string()));
assert!(state.dirty);
}
#[test]
fn test_delete_confirm_n_cancels() {
let (mut app, mut state) = test_app_with_providers();
let original_count = state.provider_ids().len();
handle_key_event(key(KeyCode::Char('d')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
assert_eq!(state.provider_ids().len(), original_count);
}
#[test]
fn test_delete_confirm_esc_cancels() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('d')), &mut app, &mut state);
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_refresh_when_dirty_shows_confirm() {
let (mut app, mut state) = test_app_with_providers();
state.dirty = true;
handle_key_event(key(KeyCode::Char('r')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ConfirmRefresh);
}
#[test]
fn test_refresh_confirm_yes_reloads() {
let (mut app, mut state) = test_app_with_providers();
state.dirty = true;
handle_key_event(key(KeyCode::Char('r')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('y')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
assert!(!state.dirty);
}
#[test]
fn test_refresh_confirm_n_cancels() {
let (mut app, mut state) = test_app_with_providers();
state.dirty = true;
handle_key_event(key(KeyCode::Char('r')), &mut app, &mut state);
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
assert!(state.dirty); }
#[test]
fn test_refresh_when_not_dirty_reloads_directly() {
let (mut app, mut state) = test_app_with_providers();
assert!(!state.dirty);
handle_key_event(key(KeyCode::Char('r')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_m_key_triggers_model_fetch() {
let (mut app, mut state) = test_app_with_providers();
app.selected_index = 0;
let action = handle_key_event(key(KeyCode::Char('m')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ModelSelector);
assert!(app.selected_provider.is_some());
assert!(matches!(
action,
AsyncAction::FetchModels {
force_refresh: false,
..
}
));
}
#[test]
fn test_r_in_model_selector_refetches() {
let (mut app, mut state) = test_app_with_providers();
let _ = handle_key_event(key(KeyCode::Char('m')), &mut app, &mut state);
app.discovered_models = vec![discovery::DiscoveredModel {
id: "gpt-5".to_string(),
name: "GPT-5".to_string(),
provider_id: "openai".to_string(),
context_length: Some(128000),
max_output_tokens: Some(16384),
input_cost_per_million: Some(10.0),
output_cost_per_million: Some(30.0),
}];
let action = handle_key_event(key(KeyCode::Char('r')), &mut app, &mut state);
assert!(app.discovered_models.is_empty());
assert!(matches!(
action,
AsyncAction::FetchModels {
force_refresh: true,
..
}
));
}
#[test]
fn test_save_marks_dirty() {
let (mut app, mut state) = test_app_with_providers();
state.dirty = true;
let _ = handle_key_event(key(KeyCode::Char('s')), &mut app, &mut state);
}
#[test]
fn test_add_provider_with_all_fields() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
for c in "mistral".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
for c in "Mistral AI".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
for _ in 0..3 {
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.sdk.highlight, 3); }
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
for c in "https://api.mistral.ai".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
let provider = state.get_provider("mistral").unwrap();
assert_eq!(provider.name.as_deref(), Some("Mistral AI"));
assert_eq!(provider.npm.as_deref(), Some("@mistralai/sdk"));
let base_url = provider
.options
.as_ref()
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str());
assert_eq!(base_url, Some("https://api.mistral.ai"));
}
#[test]
fn test_add_provider_shift_tab_goes_back() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 1);
}
handle_key_event(key(KeyCode::BackTab), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.focus, 0);
}
}
#[test]
fn test_all_provider_ids_includes_builtin() {
let (_app, state) = test_app_with_providers();
let all = all_provider_ids(&state);
let configured_count = state.provider_ids().len();
let builtin_new = BUILTIN_PROVIDERS
.iter()
.filter(|(id, _, _)| !state.provider_ids().contains(&id.to_string()))
.count();
assert_eq!(all.len(), configured_count + builtin_new);
assert!(all.iter().any(|(_, b)| !b));
assert!(all.iter().any(|(_, b)| *b));
}
#[test]
fn test_builtin_provider_config() {
let pc = builtin_provider_config("openai").unwrap();
assert_eq!(pc.name.as_deref(), Some("OpenAI"));
assert_eq!(pc.npm.as_deref(), Some("openai"));
assert!(builtin_provider_config("nonexistent").is_none());
}
#[test]
fn test_enter_on_builtin_auto_adds() {
let (mut app, mut state) = test_app_with_providers();
let all = all_provider_ids(&state);
let builtin_idx = all.iter().position(|(_, b)| *b).unwrap();
app.selected_index = builtin_idx;
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
assert!(matches!(app.mode, AppMode::EditProvider(_)));
if let AppMode::EditProvider(ref form) = app.mode {
assert!(state.get_provider(&form.provider_id).is_some());
}
}
#[test]
fn test_copy_source_list() {
let (_app, state) = test_app_with_providers();
let sources = copy_source_list(&state);
assert!(sources.len() >= state.provider_ids().len());
let configured = state.provider_ids();
for id in &configured {
assert!(sources.iter().any(|(sid, _)| sid == id));
}
assert!(sources.iter().any(|(sid, _)| sid == "groq")); }
#[test]
fn test_add_provider_copy_mode() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut app,
&mut state,
);
if let AppMode::AddProvider(ref form) = app.mode {
assert!(form.show_copy_list);
}
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert!(!form.show_copy_list);
}
}
#[test]
fn test_add_provider_copy_selects() {
let (mut app, mut state) = test_app_with_providers();
handle_key_event(key(KeyCode::Char('n')), &mut app, &mut state);
handle_key_event(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut app,
&mut state,
);
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
if let AppMode::AddProvider(ref form) = app.mode {
assert!(!form.show_copy_list);
assert!(!form.id.is_empty() || !form.name.is_empty());
}
}
}