use anyhow::{Context, Result};
use opencode_provider_manager::discovery::provider_api::ModelDiscovery as _;
use opencode_provider_manager::{app, config_core, discovery, omo_config};
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,
pub discovery_source: DiscoverySource,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
MergedView,
SplitView,
ProviderList,
AuthStatus,
ModelSelector,
ConfigDetail,
Help,
ConfirmDelete(String),
ConfirmRefresh,
AddProvider(AddProviderForm),
EditProvider(EditProviderForm),
Import(ImportForm),
OmoConfig(OmoConfigState),
}
pub const KNOWN_SDKS: &[&str] = &[
"@ai-sdk/openai",
"@ai-sdk/openai-compatible",
"@ai-sdk/anthropic",
"@ai-sdk/google",
"@ai-sdk/groq",
"@ai-sdk/mistral",
"@ai-sdk/amazon-bedrock",
"@ai-sdk/azure",
"Custom...",
];
pub fn all_provider_ids(state: &AppState) -> Vec<String> {
state.provider_ids()
}
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));
}
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",
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportForm {
pub focus: usize,
pub source: String,
pub layer_index: usize,
pub mode_index: usize,
pub provider_id: String,
pub result_message: Option<String>,
}
impl ImportForm {
pub fn new() -> Self {
Self {
focus: 0,
source: String::new(),
layer_index: 0,
mode_index: 0,
provider_id: String::new(),
result_message: None,
}
}
pub fn field_labels() -> [&'static str; 4] {
[
"Source (URL/path/snippet)",
"Target Layer (↑↓)",
"Import Mode (↑↓)",
"Provider ID (optional)",
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmoAgentEditForm {
pub agent_id: String,
pub focus: usize,
pub model: String,
pub fallback: String,
pub disable: bool,
pub temperature: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OmoConfigState {
pub manager: omo_config::AgentConfigManager,
pub agents: Vec<(String, omo_config::AgentDefinition)>,
pub selected_index: usize,
pub edit_mode: bool,
pub edit_form: Option<OmoAgentEditForm>,
pub layer: omo_config::ConfigLayer,
pub available_models: Vec<String>,
pub models_loading: bool,
pub dirty: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscoverySource {
ModelsDev,
ProviderApi,
}
impl DiscoverySource {
pub fn label(&self) -> &'static str {
match self {
DiscoverySource::ModelsDev => "models.dev",
DiscoverySource::ProviderApi => "Provider API",
}
}
pub fn toggle(&self) -> Self {
match self {
DiscoverySource::ModelsDev => DiscoverySource::ProviderApi,
DiscoverySource::ProviderApi => DiscoverySource::ModelsDev,
}
}
}
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,
discovery_source: DiscoverySource::ModelsDev,
}
}
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);
}
AppMode::Import(form) => {
ui::render_import(frame, form);
}
AppMode::OmoConfig(omo_state) => {
ui::render_omo_config(frame, self, omo_state);
}
}
}
}
enum AsyncAction {
FetchModels {
provider_id: String,
force_refresh: bool,
},
FetchModelsFromApi {
provider_id: String,
base_url: String,
api_key: Option<String>,
},
FetchOmoModels,
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::FetchModelsFromApi {
provider_id,
base_url,
api_key,
} => {
app.models_loading = true;
terminal.draw(|frame| app.render(frame, &state))?;
let result = if provider_id == "ollama" {
discovery::provider_api::OllamaDiscovery::new(&base_url)
.discover_models(None)
.await
} else {
discovery::provider_api::OpenAICompatibleDiscovery::new(
&provider_id,
&base_url,
)
.discover_models(api_key.as_deref())
.await
};
match result {
Ok(models) => {
app.discovered_models = models;
}
Err(e) => {
app.error_message =
Some(format!("Failed to fetch models from API: {e}"));
}
}
app.models_loading = false;
}
AsyncAction::FetchOmoModels => {
{
if let AppMode::OmoConfig(ref mut omo_state) = app.mode {
omo_state.models_loading = true;
}
}
terminal.draw(|frame| app.render(frame, &state))?;
let models = tokio::task::spawn_blocking(fetch_omo_models_sync).await;
{
if let AppMode::OmoConfig(ref mut omo_state) = app.mode {
match models {
Ok(Ok(model_set)) => {
let mut model_list: Vec<String> =
model_set.into_iter().collect();
model_list.sort();
omo_state.available_models = model_list;
}
Ok(Err(e)) => {
app.error_message =
Some(format!("Failed to fetch models: {e}"));
}
Err(e) => {
app.error_message =
Some(format!("Failed to fetch models: {e}"));
}
}
omo_state.models_loading = false;
}
}
}
AsyncAction::None => {}
}
}
}
}
}
Ok(())
}
fn extract_agents(
config: &omo_config::OhMyOpencodeConfig,
) -> Vec<(String, omo_config::AgentDefinition)> {
let mut agents = Vec::new();
if let Some(ref agent_configs) = config.agents {
let mut push = |id: &str, agent: &Option<omo_config::AgentDefinition>| {
if let Some(a) = agent {
agents.push((id.to_string(), a.clone()));
}
};
push("build", &agent_configs.build);
push("plan", &agent_configs.plan);
push("sisyphus", &agent_configs.sisyphus);
push("hephaestus", &agent_configs.hephaestus);
push("prometheus", &agent_configs.prometheus);
push("oracle", &agent_configs.oracle);
push("librarian", &agent_configs.librarian);
push("explore", &agent_configs.explore);
push("multimodal-looker", &agent_configs.multimodal_looker);
push("metis", &agent_configs.metis);
push("momus", &agent_configs.momus);
push("atlas", &agent_configs.atlas);
for (id, agent) in &agent_configs.custom {
agents.push((id.clone(), agent.clone()));
}
}
agents
}
fn initialize_omo_config() -> Result<OmoConfigState> {
let mut manager = omo_config::AgentConfigManager::new()?;
manager.load_all()?;
let layer = omo_config::ConfigLayer::Project;
let config = manager.project_config.clone().unwrap_or_default();
let agents = extract_agents(&config);
Ok(OmoConfigState {
manager,
agents,
selected_index: 0,
edit_mode: false,
edit_form: None,
layer,
available_models: Vec::new(),
models_loading: false,
dirty: false,
})
}
fn update_agent_in_state(omo_state: &mut OmoConfigState, form: &OmoAgentEditForm) -> Result<()> {
let config = match omo_state.layer {
omo_config::ConfigLayer::Global => omo_state.manager.global_config.as_mut(),
omo_config::ConfigLayer::Project => omo_state.manager.project_config.as_mut(),
}
.ok_or_else(|| anyhow::anyhow!("No config loaded for current layer"))?;
if config.agents.is_none() {
config.agents = Some(omo_config::AgentsConfig::default());
}
let agents = config.agents.as_mut().unwrap();
let agent = match form.agent_id.as_str() {
"build" => agents
.build
.get_or_insert_with(omo_config::AgentDefinition::default),
"plan" => agents
.plan
.get_or_insert_with(omo_config::AgentDefinition::default),
"sisyphus" => agents
.sisyphus
.get_or_insert_with(omo_config::AgentDefinition::default),
"hephaestus" => agents
.hephaestus
.get_or_insert_with(omo_config::AgentDefinition::default),
"prometheus" => agents
.prometheus
.get_or_insert_with(omo_config::AgentDefinition::default),
"oracle" => agents
.oracle
.get_or_insert_with(omo_config::AgentDefinition::default),
"librarian" => agents
.librarian
.get_or_insert_with(omo_config::AgentDefinition::default),
"explore" => agents
.explore
.get_or_insert_with(omo_config::AgentDefinition::default),
"multimodal-looker" => agents
.multimodal_looker
.get_or_insert_with(omo_config::AgentDefinition::default),
"metis" => agents
.metis
.get_or_insert_with(omo_config::AgentDefinition::default),
"momus" => agents
.momus
.get_or_insert_with(omo_config::AgentDefinition::default),
"atlas" => agents
.atlas
.get_or_insert_with(omo_config::AgentDefinition::default),
custom => agents
.custom
.entry(custom.to_string())
.or_insert_with(omo_config::AgentDefinition::default),
};
let model_trimmed = form.model.trim().to_string();
agent.model = if model_trimmed.is_empty() {
None
} else {
Some(model_trimmed)
};
let fallback_trimmed = form.fallback.trim().to_string();
agent.fallback_models = if fallback_trimmed.is_empty() {
None
} else {
let fb_ids: Vec<String> = fallback_trimmed
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if fb_ids.is_empty() {
None
} else {
Some(omo_config::FallbackModels::StringList(fb_ids))
}
};
agent.disable = Some(form.disable);
let temp_trimmed = form.temperature.trim().to_string();
agent.temperature = if temp_trimmed.is_empty() {
None
} else {
Some(
temp_trimmed
.parse::<f64>()
.map_err(|e| anyhow::anyhow!("Invalid temperature: {e}"))?,
)
};
omo_state.dirty = true;
let config_ref = match omo_state.layer {
omo_config::ConfigLayer::Global => omo_state.manager.global_config.as_ref(),
omo_config::ConfigLayer::Project => omo_state.manager.project_config.as_ref(),
}
.unwrap();
omo_state.agents = extract_agents(config_ref);
Ok(())
}
fn save_omo_config(omo_state: &mut OmoConfigState) -> Result<()> {
let config = match omo_state.layer {
omo_config::ConfigLayer::Global => omo_state.manager.global_config.as_ref(),
omo_config::ConfigLayer::Project => omo_state.manager.project_config.as_ref(),
}
.ok_or_else(|| anyhow::anyhow!("No config loaded for current layer"))?;
omo_state.manager.save(omo_state.layer, config)?;
Ok(())
}
fn fetch_omo_models_sync() -> Result<std::collections::HashSet<String>> {
let output = if cfg!(target_os = "windows") {
std::process::Command::new("opencode.cmd")
.args(["models"])
.output()
.or_else(|_| {
std::process::Command::new("cmd")
.args(["/c", "opencode", "models"])
.output()
})
.context("Failed to run 'opencode models'")?
} else {
std::process::Command::new("opencode")
.args(["models"])
.output()
.context("Failed to run 'opencode models'")?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"'opencode models' exited with code {:?}: {}",
output.status.code(),
stderr
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
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::Import(ref mut form) = app.mode {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
KeyCode::Tab => {
form.focus = (form.focus + 1) % ImportForm::field_labels().len();
}
KeyCode::BackTab => {
form.focus = (form.focus + ImportForm::field_labels().len() - 1)
% ImportForm::field_labels().len();
}
KeyCode::Up => match form.focus {
1 if form.layer_index > 0 => {
form.layer_index -= 1;
}
2 if form.mode_index > 0 => {
form.mode_index -= 1;
}
_ => {}
},
KeyCode::Down => match form.focus {
1 if form.layer_index < 2 => {
form.layer_index += 1;
}
2 if form.mode_index < 1 => {
form.mode_index += 1;
}
_ => {}
},
KeyCode::Backspace => match form.focus {
0 => {
form.source.pop();
}
3 => {
form.provider_id.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => form.source.push(c),
3 => form.provider_id.push(c),
_ => {}
},
KeyCode::Enter => {
let source = form.source.trim().to_string();
if source.is_empty() {
form.result_message = Some("Source cannot be empty".to_string());
} else {
let layer = match form.layer_index {
0 => config_core::ConfigLayer::Project,
1 => config_core::ConfigLayer::Global,
2 => config_core::ConfigLayer::Custom,
_ => config_core::ConfigLayer::Project,
};
let mode = if form.mode_index == 0 {
app::import::ImportMergeMode::Merge
} else {
app::import::ImportMergeMode::Replace
};
let provider_hint = if form.provider_id.trim().is_empty() {
None
} else {
Some(form.provider_id.trim().to_string())
};
match app::import::import_source(
state,
&source,
provider_hint.as_deref(),
layer,
mode,
) {
Ok(summary) => {
form.result_message = Some(format!(
"OK: {} provider(s), {} model(s): {}",
summary.provider_count,
summary.model_count,
if summary.provider_ids.is_empty() {
"(none)".to_string()
} else {
summary.provider_ids.join(", ")
}
));
}
Err(e) => {
form.result_message = Some(format!("Import failed: {e:#}"));
}
}
}
}
_ => {}
}
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) {
if let Some(provider) = state.get_provider(id).cloned() {
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;
}
if let AppMode::OmoConfig(ref mut omo_state) = app.mode {
if omo_state.edit_mode {
if let Some(ref mut form) = omo_state.edit_form {
match key.code {
KeyCode::Esc => {
omo_state.edit_mode = false;
omo_state.edit_form = None;
}
KeyCode::Tab => {
form.focus = (form.focus + 1) % 4;
}
KeyCode::BackTab => {
form.focus = (form.focus + 3) % 4;
}
KeyCode::Enter => {
let form_data = omo_state.edit_form.take().unwrap();
if let Err(e) = update_agent_in_state(omo_state, &form_data) {
app.error_message = Some(format!("Failed to save agent: {e}"));
omo_state.edit_form = Some(form_data);
} else {
omo_state.edit_mode = false;
}
}
KeyCode::Backspace => match form.focus {
0 => {
form.model.pop();
}
1 => {
form.fallback.pop();
}
3 => {
form.temperature.pop();
}
_ => {}
},
KeyCode::Char(c) => match form.focus {
0 => form.model.push(c),
1 => form.fallback.push(c),
3 => form.temperature.push(c),
2 if c == ' ' => {
form.disable = !form.disable;
}
_ => {}
},
_ => {}
}
}
} else {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::ProviderList;
}
KeyCode::Char('o') => {
omo_state.layer = match omo_state.layer {
omo_config::ConfigLayer::Global => omo_config::ConfigLayer::Project,
omo_config::ConfigLayer::Project => omo_config::ConfigLayer::Global,
};
let config = match omo_state.layer {
omo_config::ConfigLayer::Global => {
omo_state.manager.global_config.clone().unwrap_or_default()
}
omo_config::ConfigLayer::Project => {
omo_state.manager.project_config.clone().unwrap_or_default()
}
};
omo_state.agents = extract_agents(&config);
omo_state.selected_index = 0;
}
KeyCode::Char('s') => {
if let Err(e) = save_omo_config(omo_state) {
app.error_message = Some(format!("Save failed: {e}"));
} else {
omo_state.dirty = false;
app.error_message = Some("Agent config saved".to_string());
}
}
KeyCode::Up | KeyCode::Char('k') if omo_state.selected_index > 0 => {
omo_state.selected_index -= 1;
}
KeyCode::Down | KeyCode::Char('j')
if omo_state.selected_index + 1 < omo_state.agents.len() =>
{
omo_state.selected_index += 1;
}
KeyCode::Enter => {
if let Some((agent_id, agent)) = omo_state.agents.get(omo_state.selected_index)
{
let fallback_str = match &agent.fallback_models {
Some(omo_config::FallbackModels::Single(s)) => s.clone(),
Some(omo_config::FallbackModels::StringList(list)) => list.join(", "),
Some(omo_config::FallbackModels::DetailedList(list)) => list
.iter()
.map(|spec| spec.model.clone())
.collect::<Vec<_>>()
.join(", "),
Some(omo_config::FallbackModels::MixedList(list)) => list
.iter()
.map(|entry| match entry {
omo_config::FallbackModelEntry::String(s) => s.clone(),
omo_config::FallbackModelEntry::Detailed(spec) => {
spec.model.clone()
}
})
.collect::<Vec<_>>()
.join(", "),
None => String::new(),
};
omo_state.edit_form = Some(OmoAgentEditForm {
agent_id: agent_id.clone(),
focus: 0,
model: agent.model.clone().unwrap_or_default(),
fallback: fallback_str,
disable: agent.disable.unwrap_or(false),
temperature: agent
.temperature
.map(|t| t.to_string())
.unwrap_or_default(),
});
omo_state.edit_mode = true;
}
}
_ => {}
}
}
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('o') => {
match initialize_omo_config() {
Ok(omo_state) => {
app.mode = AppMode::OmoConfig(omo_state);
async_action = AsyncAction::FetchOmoModels;
}
Err(e) => {
app.error_message = Some(format!("Failed to load agent config: {e}"));
}
}
}
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(provider_id) = app.selected_provider.clone() {
app.discovered_models.clear();
async_action = match app.discovery_source {
DiscoverySource::ModelsDev => AsyncAction::FetchModels {
provider_id: provider_id.clone(),
force_refresh: true,
},
DiscoverySource::ProviderApi => {
let base_url = state
.get_provider(&provider_id)
.and_then(|p| p.options.as_ref())
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str())
.unwrap_or("http://localhost:11434")
.to_string();
let api_key = state
.get_provider(&provider_id)
.and_then(|p| p.options.as_ref())
.and_then(|o| o.get("apiKey"))
.and_then(|v| v.as_str())
.map(str::to_string);
AsyncAction::FetchModelsFromApi {
provider_id: provider_id.clone(),
base_url,
api_key,
}
}
};
}
} 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('g') if app.mode == AppMode::ProviderList => {
let provider_ids = state.provider_ids();
if let Some(provider_id) = provider_ids.get(app.selected_index) {
match state.copy_provider_to_global(provider_id) {
Ok(()) => {
state.edit_layer = config_core::ConfigLayer::Global;
app.error_message = Some(format!(
"Provider '{provider_id}' copied to global — layer switched to Global, press s to save"
));
}
Err(e) => {
app.error_message = Some(format!("Copy to global failed: {e}"));
}
}
}
}
KeyCode::Char('t') if app.mode == AppMode::ModelSelector => {
app.discovery_source = app.discovery_source.toggle();
app.discovered_models.clear();
if let Some(ref provider_id) = app.selected_provider.clone() {
async_action = match app.discovery_source {
DiscoverySource::ModelsDev => AsyncAction::FetchModels {
provider_id: provider_id.clone(),
force_refresh: false,
},
DiscoverySource::ProviderApi => {
let base_url = state
.get_provider(provider_id)
.and_then(|p| p.options.as_ref())
.and_then(|o| o.get("baseURL"))
.and_then(|v| v.as_str())
.unwrap_or("http://localhost:11434")
.to_string();
let api_key = state
.get_provider(provider_id)
.and_then(|p| p.options.as_ref())
.and_then(|o| o.get("apiKey"))
.and_then(|v| v.as_str())
.map(str::to_string);
AsyncAction::FetchModelsFromApi {
provider_id: provider_id.clone(),
base_url,
api_key,
}
}
};
}
}
KeyCode::Char('n') if app.mode == AppMode::ProviderList => {
app.mode = AppMode::AddProvider(AddProviderForm::new());
}
KeyCode::Char('i') if app.mode == AppMode::ProviderList => {
app.mode = AppMode::Import(ImportForm::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') => {
let max = if app.mode == AppMode::ProviderList {
all_provider_ids(state).len().saturating_sub(1)
} else {
usize::MAX
};
if app.selected_index < max {
app.on_event(AppEvent::SelectIndex(app.selected_index + 1));
}
}
KeyCode::Enter => {
if app.mode == AppMode::ProviderList {
let all_ids = all_provider_ids(state);
if let Some(provider_id) = all_ids.get(app.selected_index) {
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 {
name: if model.name.is_empty() || model.name == model.id {
None
} else {
Some(model.name.clone())
},
limit: match (model.context_length, model.max_output_tokens) {
(None, None) => None,
(ctx, out) => Some(config_core::ModelLimit {
context: ctx,
output: out,
}),
},
..Default::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!(form.sdk.custom_mode);
assert_eq!(form.sdk.custom_text, "openai");
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..5 {
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
}
if let AppMode::AddProvider(ref form) = app.mode {
assert_eq!(form.sdk.highlight, 5); }
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("@ai-sdk/mistral"));
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_returns_configured() {
let (_app, state) = test_app_with_providers();
let all = all_provider_ids(&state);
assert_eq!(all.len(), state.provider_ids().len());
for id in state.provider_ids() {
assert!(all.contains(&id));
}
}
#[test]
fn test_copy_source_list() {
let (_app, state) = test_app_with_providers();
let sources = copy_source_list(&state);
assert_eq!(sources.len(), state.provider_ids().len());
for id in state.provider_ids() {
assert!(sources.iter().any(|(sid, _)| *sid == id));
}
}
#[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());
}
}
#[test]
fn test_i_opens_import() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::ProviderList;
handle_key_event(key(KeyCode::Char('i')), &mut app, &mut state);
assert!(matches!(app.mode, AppMode::Import(_)));
}
#[test]
fn test_import_esc_cancels() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::Import(ImportForm::new());
handle_key_event(key(KeyCode::Esc), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ProviderList);
}
#[test]
fn test_import_tab_cycles() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::Import(ImportForm::new());
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.focus, 1);
}
}
#[test]
fn test_import_shift_tab_goes_back() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::Import(ImportForm::new());
handle_key_event(key(KeyCode::Tab), &mut app, &mut state);
handle_key_event(key(KeyCode::BackTab), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.focus, 0);
}
}
#[test]
fn test_import_type_source() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::Import(ImportForm::new());
for c in "https://example.com/config.json".chars() {
handle_key_event(key(KeyCode::Char(c)), &mut app, &mut state);
}
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.source, "https://example.com/config.json");
}
}
#[test]
fn test_import_empty_source_shows_error() {
let (mut app, mut state) = test_app_with_providers();
app.mode = AppMode::Import(ImportForm::new());
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert!(form.result_message.is_some());
assert!(form.result_message.as_ref().unwrap().contains("empty"));
}
}
#[test]
fn test_import_layer_toggle() {
let (mut app, mut state) = test_app_with_providers();
let mut form = ImportForm::new();
form.focus = 1; app.mode = AppMode::Import(form);
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.layer_index, 1); }
}
#[test]
fn test_import_mode_toggle() {
let (mut app, mut state) = test_app_with_providers();
let mut form = ImportForm::new();
form.focus = 2; app.mode = AppMode::Import(form);
handle_key_event(key(KeyCode::Down), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.mode_index, 1); }
}
#[test]
fn test_import_backspace() {
let (mut app, mut state) = test_app_with_providers();
let mut form = ImportForm::new();
form.source = "test".to_string();
app.mode = AppMode::Import(form);
handle_key_event(key(KeyCode::Backspace), &mut app, &mut state);
if let AppMode::Import(ref form) = app.mode {
assert_eq!(form.source, "tes");
}
}
fn test_app_with_project_provider() -> (App, AppState) {
let mut state = AppState::new().unwrap();
state.edit_layer = config_core::ConfigLayer::Project;
let mut project_models = std::collections::HashMap::new();
project_models.insert("gpt-4o".to_string(), config_core::ModelConfig::default());
let project_provider = config_core::ProviderConfig {
npm: Some("@ai-sdk/openai".to_string()),
name: Some("OpenAI".to_string()),
models: Some(project_models),
..Default::default()
};
state.project_config = Some(config_core::OpenCodeConfig::default());
state
.project_config
.as_mut()
.unwrap()
.provider
.get_or_insert_with(std::collections::HashMap::new)
.insert("openai".to_string(), project_provider);
state.recompute_merged();
let app = App::new();
(app, state)
}
#[test]
fn test_g_copies_provider_to_global() {
let (mut app, mut state) = test_app_with_project_provider();
let idx = provider_index(&state, "openai");
app.selected_index = idx;
handle_key_event(key(KeyCode::Char('g')), &mut app, &mut state);
assert!(app.error_message.is_some());
let msg = app.error_message.as_ref().unwrap();
assert!(msg.contains("openai"), "message should mention provider id");
let global = state.global_config.as_ref().unwrap();
assert!(global.provider.as_ref().unwrap().contains_key("openai"));
assert!(state.dirty);
assert_eq!(state.edit_layer, config_core::ConfigLayer::Global);
}
#[test]
fn test_g_noop_in_other_modes() {
let (mut app, mut state) = test_app_with_project_provider();
app.mode = AppMode::MergedView;
handle_key_event(key(KeyCode::Char('g')), &mut app, &mut state);
assert!(state.global_config.is_none() || !state.dirty);
}
#[test]
fn test_discovery_source_default_is_models_dev() {
let app = App::new();
assert_eq!(app.discovery_source, DiscoverySource::ModelsDev);
}
#[test]
fn test_t_toggles_discovery_source() {
let (mut app, mut state) = test_app_with_providers();
let _ = handle_key_event(key(KeyCode::Char('m')), &mut app, &mut state);
assert_eq!(app.mode, AppMode::ModelSelector);
assert_eq!(app.discovery_source, DiscoverySource::ModelsDev);
handle_key_event(key(KeyCode::Char('t')), &mut app, &mut state);
assert_eq!(app.discovery_source, DiscoverySource::ProviderApi);
handle_key_event(key(KeyCode::Char('t')), &mut app, &mut state);
assert_eq!(app.discovery_source, DiscoverySource::ModelsDev);
}
#[test]
fn test_t_only_works_in_model_selector() {
let (mut app, mut state) = test_app_with_providers();
assert_eq!(app.mode, AppMode::ProviderList);
handle_key_event(key(KeyCode::Char('t')), &mut app, &mut state);
assert_eq!(app.discovery_source, DiscoverySource::ModelsDev);
}
#[test]
fn test_enter_in_model_selector_prefills_model_config() {
let (mut app, mut state) = test_app_with_project_provider();
app.mode = AppMode::ModelSelector;
app.selected_provider = Some("openai".to_string());
app.selected_index = 0;
app.discovered_models = vec![discovery::DiscoveredModel {
id: "gpt-4o".to_string(),
name: "GPT-4o".to_string(),
provider_id: "openai".to_string(),
context_length: Some(128_000),
max_output_tokens: Some(16_384),
input_cost_per_million: Some(5.0),
output_cost_per_million: Some(15.0),
}];
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
let provider = state.get_provider("openai").unwrap();
let models = provider.models.as_ref().unwrap();
let model = models.get("gpt-4o").unwrap();
assert_eq!(model.name.as_deref(), Some("GPT-4o"));
let limit = model.limit.as_ref().unwrap();
assert_eq!(limit.context, Some(128_000));
assert_eq!(limit.output, Some(16_384));
}
#[test]
fn test_enter_in_model_selector_name_not_set_when_same_as_id() {
let (mut app, mut state) = test_app_with_project_provider();
app.mode = AppMode::ModelSelector;
app.selected_provider = Some("openai".to_string());
app.selected_index = 0;
app.discovered_models = vec![discovery::DiscoveredModel {
id: "gpt-4o".to_string(),
name: "gpt-4o".to_string(), provider_id: "openai".to_string(),
context_length: None,
max_output_tokens: None,
input_cost_per_million: None,
output_cost_per_million: None,
}];
handle_key_event(key(KeyCode::Enter), &mut app, &mut state);
let provider = state.get_provider("openai").unwrap();
let models = provider.models.as_ref().unwrap();
let model = models.get("gpt-4o").unwrap();
assert_eq!(model.name, None);
assert_eq!(model.limit, None);
}
}