use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, List, ListItem, Padding, StatefulWidget, Widget},
};
use super::super::theme::*;
#[derive(Debug, Clone, PartialEq)]
pub struct ModelOption {
pub id: String,
pub name: String,
pub description: String,
pub available: bool,
}
impl ModelOption {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
available: true,
}
}
pub fn with_available(mut self, available: bool) -> Self {
self.available = available;
self
}
}
pub fn default_models() -> Vec<ModelOption> {
vec![
ModelOption::new(
"claude-code",
"Claude Code",
"Anthropic's Claude with code tools",
),
ModelOption::new("opencode", "OpenCode", "OpenAI-based code assistant"),
ModelOption::new("codex", "Codex", "OpenAI Codex for code completion")
.with_available(false),
]
}
#[derive(Debug, Default)]
pub struct ModelSelectorState {
pub selected: usize,
pub offset: usize,
}
impl ModelSelectorState {
pub fn new(selected: usize) -> Self {
Self {
selected,
offset: 0,
}
}
pub fn previous(&mut self, total: usize) {
if total == 0 {
return;
}
self.selected = if self.selected > 0 {
self.selected - 1
} else {
total - 1
};
}
pub fn next(&mut self, total: usize) {
if total == 0 {
return;
}
self.selected = (self.selected + 1) % total;
}
pub fn adjust_scroll(&mut self, visible_height: usize) {
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + visible_height {
self.offset = self.selected.saturating_sub(visible_height - 1);
}
}
}
pub struct ModelSelector<'a> {
models: &'a [ModelOption],
focused: bool,
title: String,
}
impl<'a> ModelSelector<'a> {
pub fn new(models: &'a [ModelOption]) -> Self {
Self {
models,
focused: false,
title: "Select Model".to_string(),
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
}
impl StatefulWidget for ModelSelector<'_> {
type State = ModelSelectorState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let border_color = if self.focused {
BORDER_ACTIVE
} else {
BORDER_DEFAULT
};
let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Line::from(format!(" {} ", self.title)).fg(title_color))
.style(Style::default().bg(BG_SECONDARY))
.padding(Padding::new(1, 1, 0, 0));
let inner = block.inner(area);
let visible_height = inner.height as usize;
state.adjust_scroll(visible_height);
let items: Vec<ListItem> = self
.models
.iter()
.enumerate()
.skip(state.offset)
.take(visible_height)
.map(|(i, model)| {
let is_selected = i == state.selected && self.focused;
let icon = if model.available {
("●", SUCCESS)
} else {
("○", TEXT_MUTED)
};
let prefix = if is_selected { "▸ " } else { " " };
let line = Line::from(vec![
Span::styled(prefix, Style::default().fg(ACCENT)),
Span::styled(format!("{} ", icon.0), Style::default().fg(icon.1)),
Span::styled(
&model.name,
Style::default()
.fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
.add_modifier(if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
Span::styled(
format!(" - {}", model.description),
Style::default().fg(TEXT_MUTED),
),
]);
ListItem::new(line)
})
.collect();
Widget::render(block, area, buf);
let list = List::new(items);
Widget::render(list, inner, buf);
}
}
pub struct ModelSelectorCompact<'a> {
selected: &'a ModelOption,
focused: bool,
}
impl<'a> ModelSelectorCompact<'a> {
pub fn new(selected: &'a ModelOption) -> Self {
Self {
selected,
focused: false,
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl Widget for ModelSelectorCompact<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 10 || area.height < 1 {
return;
}
let style = if self.focused {
Style::default().fg(ACCENT)
} else {
Style::default().fg(TEXT_PRIMARY)
};
let icon = if self.selected.available {
"●"
} else {
"○"
};
let text = format!(" {} {} ◂", icon, self.selected.name);
let span = Span::styled(text, style);
buf.set_span(area.x, area.y, &span, area.width);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_option_creation() {
let model = ModelOption::new("test", "Test Model", "A test model");
assert_eq!(model.id, "test");
assert_eq!(model.name, "Test Model");
assert!(model.available);
}
#[test]
fn test_model_option_availability() {
let model = ModelOption::new("test", "Test", "Test").with_available(false);
assert!(!model.available);
}
#[test]
fn test_selector_state_navigation() {
let mut state = ModelSelectorState::new(0);
state.next(3);
assert_eq!(state.selected, 1);
state.next(3);
assert_eq!(state.selected, 2);
state.next(3); assert_eq!(state.selected, 0);
state.previous(3); assert_eq!(state.selected, 2);
}
#[test]
fn test_default_models() {
let models = default_models();
assert!(!models.is_empty());
assert!(models.iter().any(|m| m.id == "claude-code"));
}
}