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 {
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}"));
if let Err(e) = persist_model_choice(model_id) {
tracing::warn!("Failed to persist model choice: {e}");
}
}
}
pub(super) fn open_model_picker(&mut self) {
let models = self.model_picker_models();
if models.is_empty() {
return;
}
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;
}
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,
})
}
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,
}
}
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();
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());
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);
}
}
}
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;
chat.switch_model("test-model");
let mut chat2: Chat = make_chat().await;
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"));
}
}