seval 0.1.2

AI-powered security research CLI with a split-pane TUI, agentic tool execution, and session persistence
Documentation
//! Model picker overlay logic.

use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};

use crate::action::Action;

use super::component::Chat;
use super::rendering::centered_popup;

impl Chat {
    /// Switch the active model at runtime and persist the choice to config.
    pub(super) fn switch_model(&mut self, model_id: &str) {
        if let Some(ref mut arc_provider) = self.provider {
            std::sync::Arc::make_mut(arc_provider).set_model(model_id.to_string());
            self.add_system_message(format!("Model switched to: {model_id}"));
            // Persist to global config.
            if let Err(e) = persist_model_choice(model_id) {
                tracing::warn!("Failed to persist model choice: {e}");
            }
        }
    }

    /// Open the interactive model picker overlay.
    pub(super) fn open_model_picker(&mut self) {
        let models = self.model_picker_models();
        if models.is_empty() {
            return;
        }
        // Pre-select the currently active model.
        let current = self
            .provider
            .as_ref()
            .map(|p| p.model_name().to_string())
            .unwrap_or_default();
        let index = models
            .iter()
            .position(|(id, _)| *id == current)
            .unwrap_or(0);
        self.model_picker.list_state = ratatui::widgets::ListState::default();
        self.model_picker.list_state.select(Some(index));
        self.model_picker.active = true;
    }

    /// Get the model list for the current provider.
    pub(super) fn model_picker_models(&self) -> &[(&str, &str)] {
        self.provider
            .as_ref()
            .map_or(&[], |p| match p.provider_name() {
                "bedrock" => &crate::tui::wizard::BEDROCK_MODELS,
                _ => &crate::tui::wizard::OPENROUTER_MODELS,
            })
    }

    /// Handle a key event while the model picker is active.
    pub(super) fn handle_model_picker_key(&mut self, key: KeyEvent) -> Option<Action> {
        let model_count = self.model_picker_models().len();
        match key.code {
            KeyCode::Up | KeyCode::Char('k') => {
                let i = self.model_picker.list_state.selected().unwrap_or(0);
                let new = if i == 0 { model_count - 1 } else { i - 1 };
                self.model_picker.list_state.select(Some(new));
                None
            }
            KeyCode::Down | KeyCode::Char('j') => {
                let i = self.model_picker.list_state.selected().unwrap_or(0);
                let new = (i + 1) % model_count;
                self.model_picker.list_state.select(Some(new));
                None
            }
            KeyCode::Enter => {
                let index = self.model_picker.list_state.selected().unwrap_or(0);
                let models = self.model_picker_models();
                let model_id = models[index].0.to_string();
                self.model_picker.active = false;
                self.switch_model(&model_id);
                None
            }
            KeyCode::Esc | KeyCode::Char('q') => {
                self.model_picker.active = false;
                None
            }
            _ => None,
        }
    }

    /// Draw the model picker overlay centered in the given area.
    pub(super) fn draw_model_picker(&self, frame: &mut Frame, area: Rect) {
        let models = self.model_picker_models();
        let current = self
            .provider
            .as_ref()
            .map(|p| p.model_name().to_string())
            .unwrap_or_default();

        let items: Vec<ListItem> = models
            .iter()
            .map(|(id, desc)| {
                let marker = if *id == current { " (active)" } else { "" };
                ListItem::new(vec![
                    Line::from(Span::styled(
                        format!("  {id}{marker}"),
                        Style::default().add_modifier(Modifier::BOLD),
                    )),
                    Line::from(Span::styled(
                        format!("    {desc}"),
                        Style::default().fg(Color::DarkGray),
                    )),
                ])
            })
            .collect();

        // 2 lines per model + 2 for border + 3 for title/footer padding.
        let list_height = u16::try_from(models.len() * 2).unwrap_or(20) + 5;
        let list_width = 64_u16;

        let popup_area = centered_popup(area, list_width, list_height);
        frame.render_widget(Clear, popup_area);

        let list = List::new(items)
            .block(
                Block::default()
                    .title(" Select Model ")
                    .title_alignment(Alignment::Center)
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::Cyan)),
            )
            .highlight_style(
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, popup_area, &mut self.model_picker.list_state.clone());

        // Draw hint line below the popup.
        let hint_area = Rect {
            x: popup_area.x,
            y: popup_area.y + popup_area.height,
            width: popup_area.width,
            height: 1,
        };
        if hint_area.y < area.y + area.height {
            let hint = Paragraph::new(Line::from(vec![
                Span::styled("\u{2191}\u{2193}", Style::default().fg(Color::Yellow)),
                Span::raw(": navigate  "),
                Span::styled("Enter", Style::default().fg(Color::Yellow)),
                Span::raw(": select  "),
                Span::styled("Esc", Style::default().fg(Color::Yellow)),
                Span::raw(": cancel"),
            ]))
            .alignment(Alignment::Center);
            frame.render_widget(Clear, hint_area);
            frame.render_widget(hint, hint_area);
        }
    }
}

/// Persist a model choice to the global config file.
///
/// Reads the existing config, updates `provider.model`, and writes it back.
fn persist_model_choice(model_id: &str) -> anyhow::Result<()> {
    use crate::config::{GlobalConfig, global_config_path, load_config, save_config};

    let path = global_config_path()?;
    let mut config: GlobalConfig = load_config(&path)?.unwrap_or_default();
    config.provider.model = Some(model_id.to_string());
    save_config(&config, &path)?;
    tracing::info!("Persisted model choice: {model_id}");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::super::component::Chat;
    use super::super::component::tests::make_chat;

    #[tokio::test]
    async fn slash_command_model_no_provider_shows_error() {
        let mut chat: Chat = make_chat().await;
        // Directly call open_model_picker to test the no-provider path.
        // The chat has no provider, so the slash command handler will show error.
        chat.switch_model("test-model");
        // switch_model on a chat without provider does nothing (provider is None).
        // Test via the slash command path:
        let mut chat2: Chat = make_chat().await;
        // Use the add_system_message path that the slash command would invoke.
        if chat2.provider.is_none() {
            chat2.add_system_message(
                "No AI provider configured. Set an API key in ~/.seval/config.toml".to_string(),
            );
        }
        let last = chat2.messages.last().unwrap();
        assert!(last.content.contains("No AI provider"));
    }
}