use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use super::view_trait::View;
use super::{CosmicVariant, TuiView, ViewAction};
use crate::tui::state::TuiState;
use crate::tui::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsSection {
#[default]
Providers, McpServers, Secrets, Packages, Preferences, }
impl SettingsSection {
pub const ALL: [SettingsSection; 5] = [
SettingsSection::Providers,
SettingsSection::McpServers,
SettingsSection::Secrets,
SettingsSection::Packages,
SettingsSection::Preferences,
];
pub fn next(&self) -> Self {
match self {
SettingsSection::Providers => SettingsSection::McpServers,
SettingsSection::McpServers => SettingsSection::Secrets,
SettingsSection::Secrets => SettingsSection::Packages,
SettingsSection::Packages => SettingsSection::Preferences,
SettingsSection::Preferences => SettingsSection::Providers,
}
}
pub fn prev(&self) -> Self {
match self {
SettingsSection::Providers => SettingsSection::Preferences,
SettingsSection::McpServers => SettingsSection::Providers,
SettingsSection::Secrets => SettingsSection::McpServers,
SettingsSection::Packages => SettingsSection::Secrets,
SettingsSection::Preferences => SettingsSection::Packages,
}
}
pub fn title(&self) -> &'static str {
match self {
SettingsSection::Providers => "PROVIDERS",
SettingsSection::McpServers => "MCP SERVERS",
SettingsSection::Secrets => "SECRETS",
SettingsSection::Packages => "PACKAGES",
SettingsSection::Preferences => "PREFERENCES",
}
}
pub fn number(&self) -> u8 {
match self {
SettingsSection::Providers => 1,
SettingsSection::McpServers => 2,
SettingsSection::Secrets => 3,
SettingsSection::Packages => 4,
SettingsSection::Preferences => 5,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SecretsInfo {
pub source: String,
pub keychain_count: usize,
pub daemon_available: bool,
}
pub struct SettingsView {
pub section: SettingsSection,
pub provider_name: String,
pub model_name: String,
pub theme_name: String,
pub llm_configured: usize,
pub mcp_configured: usize,
pub active_mcp_servers: Vec<String>,
pub secrets_info: SecretsInfo,
pub installed_packages: Vec<String>,
}
impl SettingsView {
pub fn new() -> Self {
Self {
section: SettingsSection::Providers,
provider_name: "Auto-detect".to_string(),
model_name: "".to_string(),
theme_name: "Dark".to_string(),
llm_configured: 0,
mcp_configured: 0,
active_mcp_servers: Vec::new(),
secrets_info: SecretsInfo {
source: "checking...".to_string(),
keychain_count: 0,
daemon_available: false,
},
installed_packages: Vec::new(),
}
}
pub fn update_provider(&mut self, provider: &str, model: &str) {
self.provider_name = provider.to_string();
self.model_name = model.to_string();
}
pub fn update_theme_name(&mut self, name: &str) {
self.theme_name = name.to_string();
}
pub fn update_provider_counts(&mut self, llm: usize, mcp: usize) {
self.llm_configured = llm;
self.mcp_configured = mcp;
}
pub fn update_mcp_servers(&mut self, servers: Vec<String>) {
self.active_mcp_servers = servers;
}
pub fn update_secrets_info(&mut self, source: &str, count: usize, daemon_available: bool) {
self.secrets_info = SecretsInfo {
source: source.to_string(),
keychain_count: count,
daemon_available,
};
}
pub fn update_packages(&mut self, packages: Vec<String>) {
self.installed_packages = packages;
}
pub fn refresh_data(&mut self) {
use crate::tui::providers::{env_var, llm_provider_ids, mcp_provider_ids};
let llm_count = llm_provider_ids()
.filter(|id| {
let var = env_var(id);
std::env::var(var).map(|v| !v.is_empty()).unwrap_or(false)
})
.count();
let mcp_count = mcp_provider_ids()
.filter(|id| {
let var = env_var(id);
std::env::var(var).map(|v| !v.is_empty()).unwrap_or(false)
})
.count();
self.update_provider_counts(llm_count, mcp_count);
#[cfg(feature = "nika-daemon")]
{
self.update_secrets_info("nika-daemon", llm_count + mcp_count, true);
}
#[cfg(not(feature = "nika-daemon"))]
{
self.update_secrets_info("env vars (fallback)", llm_count + mcp_count, false);
}
}
fn render_section(
&self,
frame: &mut Frame,
area: Rect,
section: SettingsSection,
content: Vec<Line<'_>>,
theme: &Theme,
) {
let is_selected = self.section == section;
let border_style = if is_selected {
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.border_normal)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(format!(" {} ", section.title()))
.title_style(if is_selected {
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted)
});
let paragraph = Paragraph::new(content)
.block(block)
.style(Style::default().fg(theme.text_primary));
frame.render_widget(paragraph, area);
}
}
impl Default for SettingsView {
fn default() -> Self {
Self::new()
}
}
impl View for SettingsView {
fn render(&mut self, frame: &mut Frame, area: Rect, _state: &TuiState, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(4), Constraint::Length(4), Constraint::Length(4), Constraint::Length(4), Constraint::Length(5), Constraint::Min(0), ])
.split(area);
let providers_content = vec![
Line::from(vec![
Span::styled(" LLM: ", Style::default().fg(theme.text_muted)),
Span::styled(
format!("{}/7", self.llm_configured),
Style::default()
.fg(if self.llm_configured > 0 {
theme.highlight
} else {
theme.text_muted
})
.add_modifier(Modifier::BOLD),
),
Span::styled(
" configured │ MCP: ",
Style::default().fg(theme.text_muted),
),
Span::styled(
format!("{}/6", self.mcp_configured),
Style::default()
.fg(if self.mcp_configured > 0 {
theme.highlight
} else {
theme.text_muted
})
.add_modifier(Modifier::BOLD),
),
Span::styled(" configured", Style::default().fg(theme.text_muted)),
]),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("[Ctrl+P]", Style::default().fg(theme.highlight)),
Span::styled(
" Configure providers",
Style::default().fg(theme.text_muted),
),
]),
];
self.render_section(
frame,
chunks[0],
SettingsSection::Providers,
providers_content,
theme,
);
let mcp_servers_display = if self.active_mcp_servers.is_empty() {
"none active".to_string()
} else {
self.active_mcp_servers.join(", ")
};
let mcp_content = vec![Line::from(vec![
Span::styled(" Active: ", Style::default().fg(theme.text_muted)),
Span::styled(
mcp_servers_display,
Style::default()
.fg(if self.active_mcp_servers.is_empty() {
theme.text_muted
} else {
theme.highlight
})
.add_modifier(if self.active_mcp_servers.is_empty() {
Modifier::empty()
} else {
Modifier::BOLD
}),
),
])];
self.render_section(
frame,
chunks[1],
SettingsSection::McpServers,
mcp_content,
theme,
);
let secrets_content = vec![Line::from(vec![
Span::styled(" Source: ", Style::default().fg(theme.text_muted)),
Span::styled(
&self.secrets_info.source,
Style::default()
.fg(if self.secrets_info.daemon_available {
theme.highlight
} else {
theme.text_muted
})
.add_modifier(Modifier::BOLD),
),
Span::styled(" │ Keychain: ", Style::default().fg(theme.text_muted)),
Span::styled(
format!("{} entries", self.secrets_info.keychain_count),
Style::default().fg(theme.text_primary),
),
])];
self.render_section(
frame,
chunks[2],
SettingsSection::Secrets,
secrets_content,
theme,
);
let packages_display = if self.installed_packages.is_empty() {
"none installed".to_string()
} else {
self.installed_packages.join(", ")
};
let packages_content = vec![Line::from(vec![
Span::styled(" Installed: ", Style::default().fg(theme.text_muted)),
Span::styled(
packages_display,
Style::default().fg(if self.installed_packages.is_empty() {
theme.text_muted
} else {
theme.text_primary
}),
),
])];
self.render_section(
frame,
chunks[3],
SettingsSection::Packages,
packages_content,
theme,
);
let preferences_content = vec![
Line::from(vec![
Span::styled(" Theme: ", Style::default().fg(theme.text_muted)),
Span::styled(
&self.theme_name,
Style::default()
.fg(theme.highlight)
.add_modifier(Modifier::BOLD),
),
Span::styled(" │ ", Style::default().fg(theme.text_muted)),
Span::styled("[1]", Style::default().fg(theme.highlight)),
Span::styled(" Light ", Style::default().fg(theme.text_muted)),
Span::styled("[2]", Style::default().fg(theme.highlight)),
Span::styled(" Dark ", Style::default().fg(theme.text_muted)),
Span::styled("[3]", Style::default().fg(theme.highlight)),
Span::styled(" Violet", Style::default().fg(theme.text_muted)),
]),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("[?]", Style::default().fg(theme.highlight)),
Span::styled(" Help ", Style::default().fg(theme.text_muted)),
Span::styled("[Esc]", Style::default().fg(theme.highlight)),
Span::styled(" Back", Style::default().fg(theme.text_muted)),
]),
];
self.render_section(
frame,
chunks[4],
SettingsSection::Preferences,
preferences_content,
theme,
);
if chunks[5].height > 0 {
let footer = Paragraph::new(Line::from(vec![
Span::styled("[Tab]", Style::default().fg(theme.highlight)),
Span::styled(" Next ", Style::default().fg(theme.text_muted)),
Span::styled("[j/k]", Style::default().fg(theme.highlight)),
Span::styled(" Navigate ", Style::default().fg(theme.text_muted)),
Span::styled("[1-3]", Style::default().fg(theme.highlight)),
Span::styled(" Theme ", Style::default().fg(theme.text_muted)),
Span::styled("[Esc]", Style::default().fg(theme.highlight)),
Span::styled(" Back", Style::default().fg(theme.text_muted)),
]))
.style(Style::default().fg(theme.text_muted));
frame.render_widget(footer, chunks[5]);
}
}
fn handle_key(&mut self, key: KeyEvent, _state: &mut TuiState) -> ViewAction {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => ViewAction::SwitchView(TuiView::Studio),
KeyCode::Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
self.section = self.section.prev();
} else {
self.section = self.section.next();
}
ViewAction::None
}
KeyCode::BackTab => {
self.section = self.section.prev();
ViewAction::None
}
KeyCode::Char('j') | KeyCode::Down => {
self.section = self.section.next();
ViewAction::None
}
KeyCode::Char('k') | KeyCode::Up => {
self.section = self.section.prev();
ViewAction::None
}
KeyCode::Char('1') => ViewAction::SetTheme(CosmicVariant::CosmicLight),
KeyCode::Char('2') => ViewAction::SetTheme(CosmicVariant::CosmicDark),
KeyCode::Char('3') => ViewAction::SetTheme(CosmicVariant::CosmicViolet),
KeyCode::Enter if self.section == SettingsSection::Providers => {
ViewAction::VerifyProviders
}
KeyCode::Char('w') => ViewAction::LaunchWizard,
_ => ViewAction::None,
}
}
fn on_enter(&mut self, _state: &mut TuiState) {
self.refresh_data();
}
fn status_line(&self, _state: &TuiState) -> String {
format!(
"Control • {} selected | [1-3] Theme • [w] Wizard",
self.section.title()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_settings_view_new() {
let view = SettingsView::new();
assert_eq!(view.section, SettingsSection::Providers);
assert_eq!(view.provider_name, "Auto-detect");
assert_eq!(view.llm_configured, 0);
assert_eq!(view.mcp_configured, 0);
}
#[test]
fn test_section_next() {
assert_eq!(
SettingsSection::Providers.next(),
SettingsSection::McpServers
);
assert_eq!(SettingsSection::McpServers.next(), SettingsSection::Secrets);
assert_eq!(SettingsSection::Secrets.next(), SettingsSection::Packages);
assert_eq!(
SettingsSection::Packages.next(),
SettingsSection::Preferences
);
assert_eq!(
SettingsSection::Preferences.next(),
SettingsSection::Providers
);
}
#[test]
fn test_section_prev() {
assert_eq!(
SettingsSection::Providers.prev(),
SettingsSection::Preferences
);
assert_eq!(
SettingsSection::Preferences.prev(),
SettingsSection::Packages
);
assert_eq!(SettingsSection::Packages.prev(), SettingsSection::Secrets);
assert_eq!(SettingsSection::Secrets.prev(), SettingsSection::McpServers);
assert_eq!(
SettingsSection::McpServers.prev(),
SettingsSection::Providers
);
}
#[test]
fn test_section_titles() {
assert_eq!(SettingsSection::Providers.title(), "PROVIDERS");
assert_eq!(SettingsSection::McpServers.title(), "MCP SERVERS");
assert_eq!(SettingsSection::Secrets.title(), "SECRETS");
assert_eq!(SettingsSection::Packages.title(), "PACKAGES");
assert_eq!(SettingsSection::Preferences.title(), "PREFERENCES");
}
#[test]
fn test_section_numbers() {
assert_eq!(SettingsSection::Providers.number(), 1);
assert_eq!(SettingsSection::McpServers.number(), 2);
assert_eq!(SettingsSection::Secrets.number(), 3);
assert_eq!(SettingsSection::Packages.number(), 4);
assert_eq!(SettingsSection::Preferences.number(), 5);
}
#[test]
fn test_section_all_constant() {
assert_eq!(SettingsSection::ALL.len(), 5);
assert_eq!(SettingsSection::ALL[0], SettingsSection::Providers);
assert_eq!(SettingsSection::ALL[4], SettingsSection::Preferences);
}
#[test]
fn test_update_provider() {
let mut view = SettingsView::new();
view.update_provider("Claude", "claude-sonnet-4-6");
assert_eq!(view.provider_name, "Claude");
assert_eq!(view.model_name, "claude-sonnet-4-6");
}
#[test]
fn test_update_theme_name() {
let mut view = SettingsView::new();
view.update_theme_name("Light");
assert_eq!(view.theme_name, "Light");
view.update_theme_name("Solarized");
assert_eq!(view.theme_name, "Solarized");
}
#[test]
fn test_update_provider_counts() {
let mut view = SettingsView::new();
view.update_provider_counts(4, 2);
assert_eq!(view.llm_configured, 4);
assert_eq!(view.mcp_configured, 2);
}
#[test]
fn test_update_mcp_servers() {
let mut view = SettingsView::new();
view.update_mcp_servers(vec!["novanet".into(), "perplexity".into()]);
assert_eq!(view.active_mcp_servers.len(), 2);
assert!(view.active_mcp_servers.contains(&"novanet".to_string()));
}
#[test]
fn test_update_secrets_info() {
let mut view = SettingsView::new();
view.update_secrets_info("nika-daemon", 5, true);
assert_eq!(view.secrets_info.source, "nika-daemon");
assert_eq!(view.secrets_info.keychain_count, 5);
assert!(view.secrets_info.daemon_available);
}
#[test]
fn test_update_packages() {
let mut view = SettingsView::new();
view.update_packages(vec!["@spn/core@1.0".into()]);
assert_eq!(view.installed_packages.len(), 1);
}
#[test]
fn test_status_line() {
let view = SettingsView::new();
let state = TuiState::new("test");
assert!(view.status_line(&state).contains("PROVIDERS"));
}
#[test]
fn test_handle_key_escape_returns_studio() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(action, ViewAction::SwitchView(TuiView::Studio)));
}
#[test]
fn test_handle_key_tab_cycles_sections() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
assert_eq!(view.section, SettingsSection::Providers);
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::McpServers);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::Secrets);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::Packages);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::Preferences);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::Providers);
}
#[test]
fn test_handle_key_shift_tab_cycles_backwards() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
view.handle_key(key, &mut state);
assert_eq!(view.section, SettingsSection::Preferences);
}
#[test]
fn test_handle_key_question_does_nothing() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(action, ViewAction::None));
}
#[test]
fn test_handle_key_vim_navigation() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
view.handle_key(key_j, &mut state);
assert_eq!(view.section, SettingsSection::McpServers);
let key_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
view.handle_key(key_k, &mut state);
assert_eq!(view.section, SettingsSection::Providers);
}
#[test]
fn test_handle_key_enter_on_providers() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
view.section = SettingsSection::Providers;
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(action, ViewAction::VerifyProviders));
}
#[test]
fn test_handle_key_1_sets_light_theme() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(
action,
ViewAction::SetTheme(CosmicVariant::CosmicLight)
));
}
#[test]
fn test_handle_key_2_sets_dark_theme() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(
action,
ViewAction::SetTheme(CosmicVariant::CosmicDark)
));
}
#[test]
fn test_handle_key_3_sets_violet_theme() {
let mut view = SettingsView::new();
let mut state = TuiState::new("test");
let key = KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE);
let action = view.handle_key(key, &mut state);
assert!(matches!(
action,
ViewAction::SetTheme(CosmicVariant::CosmicViolet)
));
}
#[test]
fn test_secrets_info_default() {
let info = SecretsInfo::default();
assert!(info.source.is_empty());
assert_eq!(info.keychain_count, 0);
assert!(!info.daemon_available);
}
#[test]
fn test_refresh_data_updates_provider_counts() {
let mut view = SettingsView::new();
assert_eq!(view.llm_configured, 0, "Initial llm count should be 0");
assert_eq!(view.mcp_configured, 0, "Initial mcp count should be 0");
view.refresh_data();
assert!(
!view.secrets_info.source.is_empty(),
"Source should be set after refresh"
);
}
#[test]
#[serial]
fn test_refresh_data_with_env_var() {
use std::env;
let mut view = SettingsView::new();
env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-key");
view.refresh_data();
assert!(view.llm_configured >= 1, "Should detect ANTHROPIC_API_KEY");
env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
fn test_refresh_data_sets_secrets_source() {
let mut view = SettingsView::new();
view.refresh_data();
#[cfg(feature = "nika-daemon")]
assert_eq!(view.secrets_info.source, "nika-daemon");
#[cfg(not(feature = "nika-daemon"))]
assert_eq!(view.secrets_info.source, "env vars (fallback)");
}
}