use color_eyre::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Terminal,
};
use std::io;
use crate::config::{AppConfig, Provider, Theme};
#[derive(Debug, Clone, PartialEq)]
enum Step {
Welcome,
Provider,
Model,
Theme,
Network,
Summary,
}
struct WizardState {
step: Step,
config: AppConfig,
provider_list: Vec<Provider>,
provider_idx: usize,
model_list: Vec<String>,
model_idx: usize,
theme_list: Vec<Theme>,
theme_idx: usize,
network_list: Vec<&'static str>,
network_idx: usize,
error: Option<String>,
}
impl WizardState {
fn new() -> Self {
let providers = Provider::ALL.to_vec();
let provider = providers[0];
let models = provider
.suggested_models()
.iter()
.map(|s| s.to_string())
.collect();
Self {
step: Step::Welcome,
config: AppConfig::default(),
provider_list: providers,
provider_idx: 0,
model_list: models,
model_idx: 0,
theme_list: vec![Theme::Dark, Theme::Light],
theme_idx: 0,
network_list: vec!["local", "testnet", "mainnet"],
network_idx: 1, error: None,
}
}
fn selected_provider(&self) -> Provider {
self.provider_list[self.provider_idx]
}
fn selected_model(&self) -> &str {
&self.model_list[self.model_idx]
}
fn selected_theme(&self) -> Theme {
self.theme_list[self.theme_idx].clone()
}
fn selected_network(&self) -> &'static str {
self.network_list[self.network_idx]
}
fn sync_models(&mut self) {
let provider = self.selected_provider();
self.model_list = provider
.suggested_models()
.iter()
.map(|s| s.to_string())
.collect();
if self.model_idx >= self.model_list.len() {
self.model_idx = 0;
}
}
fn apply_selections(&mut self) {
self.config.provider = self.selected_provider();
self.config.default_model = self.selected_model().to_string();
self.config.theme = self.selected_theme();
self.config.default_network = self.selected_network().to_string();
}
fn next(&mut self) {
self.error = None;
match self.step {
Step::Welcome => self.step = Step::Provider,
Step::Provider => {
self.sync_models();
self.step = Step::Model;
}
Step::Model => self.step = Step::Theme,
Step::Theme => self.step = Step::Network,
Step::Network => {
self.apply_selections();
self.step = Step::Summary;
}
Step::Summary => {}
}
}
fn prev(&mut self) {
self.error = None;
match self.step {
Step::Provider => self.step = Step::Welcome,
Step::Model => self.step = Step::Provider,
Step::Theme => self.step = Step::Model,
Step::Network => self.step = Step::Theme,
Step::Summary => self.step = Step::Network,
Step::Welcome => {}
}
}
fn move_up(&mut self) {
match self.step {
Step::Provider => {
self.provider_idx = self.provider_idx.saturating_sub(1);
}
Step::Model => {
self.model_idx = self.model_idx.saturating_sub(1);
}
Step::Theme => {
self.theme_idx = self.theme_idx.saturating_sub(1);
}
Step::Network => {
self.network_idx = self.network_idx.saturating_sub(1);
}
_ => {}
}
}
#[allow(clippy::collapsible_match)]
fn move_down(&mut self) {
match self.step {
Step::Provider => {
if self.provider_idx + 1 < self.provider_list.len() {
self.provider_idx += 1;
}
}
Step::Model => {
if self.model_idx + 1 < self.model_list.len() {
self.model_idx += 1;
}
}
Step::Theme => {
if self.theme_idx + 1 < self.theme_list.len() {
self.theme_idx += 1;
}
}
Step::Network => {
if self.network_idx + 1 < self.network_list.len() {
self.network_idx += 1;
}
}
_ => {}
}
}
fn save(&mut self) -> Result<(), String> {
self.apply_selections();
self.config.save().map_err(|e| e.to_string())
}
}
struct WizardPalette {
fg: Color,
accent: Color,
selected_bg: Color,
selected_fg: Color,
dim: Color,
}
impl WizardPalette {
fn dark() -> Self {
Self {
fg: Color::White,
accent: Color::Cyan,
selected_bg: Color::DarkGray,
selected_fg: Color::Cyan,
dim: Color::Gray,
}
}
}
fn centered_area(area: Rect, width: u16, height: u16) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn render_wizard_frame(frame: &mut ratatui::Frame, state: &WizardState, palette: &WizardPalette) {
let area = frame.area();
let title = Paragraph::new(Line::from(vec![Span::styled(
" Procyon Setup ",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)]))
.alignment(ratatui::layout::Alignment::Center);
let title_area = Rect::new(0, 0, area.width, 1);
frame.render_widget(title, title_area);
match state.step {
Step::Welcome => render_welcome(frame, area, palette),
Step::Provider => render_provider(frame, area, state, palette),
Step::Model => render_model(frame, area, state, palette),
Step::Theme => render_theme(frame, area, state, palette),
Step::Network => render_network(frame, area, state, palette),
Step::Summary => render_summary(frame, area, state, palette),
}
let footer = match state.step {
Step::Welcome => "Enter: begin q: skip",
Step::Summary => "Enter: save & start Esc: go back",
_ => "Enter: next ↑/↓: select Esc: back q: skip",
};
let footer_area = Rect::new(0, area.height.saturating_sub(1), area.width, 1);
let footer_widget = Paragraph::new(Span::styled(footer, Style::default().fg(palette.dim)))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(footer_widget, footer_area);
}
fn render_welcome(frame: &mut ratatui::Frame, area: Rect, palette: &WizardPalette) {
let block_area = centered_area(area, 50, 10);
let block = Block::default()
.title(" Welcome to Procyon ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" A terminal harness for Stellar and Soroban",
Style::default().fg(palette.fg),
)),
Line::from(Span::styled(
" development, with an AI agent driving the tools.",
Style::default().fg(palette.fg),
)),
Line::from(""),
Line::from(Span::styled(
" This wizard will configure your settings.",
Style::default().fg(palette.dim),
)),
Line::from(Span::styled(
" It takes about 30 seconds.",
Style::default().fg(palette.dim),
)),
Line::from(""),
Line::from(Span::styled(
" Press Enter to begin, or q to skip and use defaults.",
Style::default().fg(palette.accent),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, block_area);
}
fn render_provider(
frame: &mut ratatui::Frame,
area: Rect,
state: &WizardState,
palette: &WizardPalette,
) {
let block_area = centered_area(area, 50, 18);
let block = Block::default()
.title(" Step 1/5 - Provider ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let desc = Paragraph::new(Line::from(Span::styled(
"Select your LLM provider:",
Style::default().fg(palette.fg),
)));
frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1));
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
);
let items: Vec<ListItem> = state
.provider_list
.iter()
.enumerate()
.map(|(i, p)| {
let style = if i == state.provider_idx {
Style::default()
.fg(palette.selected_fg)
.bg(palette.selected_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
let label = if i == state.provider_idx {
format!(" > {} ", p)
} else {
format!(" {} ", p)
};
ListItem::new(Line::from(Span::styled(label, style)))
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(Some(state.provider_idx));
frame.render_stateful_widget(list, list_area, &mut list_state);
}
fn render_model(
frame: &mut ratatui::Frame,
area: Rect,
state: &WizardState,
palette: &WizardPalette,
) {
let block_area = centered_area(area, 50, 16);
let block = Block::default()
.title(format!(
" Step 2/5 - Model ({}) ",
state.selected_provider()
))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let desc = Paragraph::new(Line::from(Span::styled(
"Select a model:",
Style::default().fg(palette.fg),
)));
frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1));
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
);
let items: Vec<ListItem> = state
.model_list
.iter()
.enumerate()
.map(|(i, m)| {
let style = if i == state.model_idx {
Style::default()
.fg(palette.selected_fg)
.bg(palette.selected_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
let label = if i == state.model_idx {
format!(" > {} ", m)
} else {
format!(" {} ", m)
};
ListItem::new(Line::from(Span::styled(label, style)))
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(Some(state.model_idx));
frame.render_stateful_widget(list, list_area, &mut list_state);
}
fn render_theme(
frame: &mut ratatui::Frame,
area: Rect,
state: &WizardState,
palette: &WizardPalette,
) {
let block_area = centered_area(area, 50, 12);
let block = Block::default()
.title(" Step 3/5 - Theme ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let desc = Paragraph::new(Line::from(Span::styled(
"Choose a color theme:",
Style::default().fg(palette.fg),
)));
frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1));
let list_area = Rect::new(
inner.x,
inner.y + 2,
inner.width,
inner.height.saturating_sub(2),
);
let items: Vec<ListItem> = state
.theme_list
.iter()
.enumerate()
.map(|(i, t)| {
let style = if i == state.theme_idx {
Style::default()
.fg(palette.selected_fg)
.bg(palette.selected_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
let label = if i == state.theme_idx {
format!(" > {} ", t)
} else {
format!(" {} ", t)
};
ListItem::new(Line::from(Span::styled(label, style)))
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(Some(state.theme_idx));
frame.render_stateful_widget(list, list_area, &mut list_state);
}
fn render_network(
frame: &mut ratatui::Frame,
area: Rect,
state: &WizardState,
palette: &WizardPalette,
) {
let block_area = centered_area(area, 50, 12);
let block = Block::default()
.title(" Step 4/5 - Network ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let desc = Paragraph::new(vec![
Line::from(Span::styled(
"Select default network:",
Style::default().fg(palette.fg),
)),
Line::from(Span::styled(
" (you can change this later with /network)",
Style::default().fg(palette.dim),
)),
]);
frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 2));
let list_area = Rect::new(
inner.x,
inner.y + 3,
inner.width,
inner.height.saturating_sub(3),
);
let items: Vec<ListItem> = state
.network_list
.iter()
.enumerate()
.map(|(i, n)| {
let style = if i == state.network_idx {
Style::default()
.fg(palette.selected_fg)
.bg(palette.selected_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
let label = if i == state.network_idx {
format!(" > {} ", n)
} else {
format!(" {} ", n)
};
ListItem::new(Line::from(Span::styled(label, style)))
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(Some(state.network_idx));
frame.render_stateful_widget(list, list_area, &mut list_state);
}
fn render_summary(
frame: &mut ratatui::Frame,
area: Rect,
state: &WizardState,
palette: &WizardPalette,
) {
let block_area = centered_area(area, 50, 16);
let block = Block::default()
.title(" Step 5/5 - Summary ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent));
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let provider = state.selected_provider();
let model = state.selected_model();
let theme = state.selected_theme();
let network = state.selected_network();
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Provider: ", Style::default().fg(palette.fg)),
Span::styled(
provider.to_string(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Model: ", Style::default().fg(palette.fg)),
Span::styled(
model.to_string(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Theme: ", Style::default().fg(palette.fg)),
Span::styled(
theme.to_string(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Network: ", Style::default().fg(palette.fg)),
Span::styled(
network.to_string(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
if let Some(ref err) = state.error {
lines.push(Line::from(Span::styled(
format!(" Error: {}", err),
Style::default().fg(Color::Red),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
" Press Enter to save and start, or Esc to go back.",
Style::default().fg(palette.dim),
)));
let paragraph = Paragraph::new(lines);
let desc_area = Rect::new(inner.x, inner.y, inner.width, inner.height);
frame.render_widget(paragraph, desc_area);
}
pub fn run_wizard(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<Option<AppConfig>> {
let mut state = WizardState::new();
let palette = WizardPalette::dark();
loop {
terminal.draw(|frame| {
render_wizard_frame(frame, &state, &palette);
})?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(None),
(KeyModifiers::NONE, KeyCode::Char('q')) => return Ok(None),
(KeyModifiers::NONE, KeyCode::Up) | (KeyModifiers::CONTROL, KeyCode::Char('p')) => {
state.move_up();
}
(KeyModifiers::NONE, KeyCode::Down)
| (KeyModifiers::CONTROL, KeyCode::Char('n')) => {
state.move_down();
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if state.step == Step::Summary {
match state.save() {
Ok(()) => return Ok(Some(state.config)),
Err(e) => {
state.error = Some(e);
}
}
} else {
state.next();
}
}
(KeyModifiers::NONE, KeyCode::Esc) => {
state.prev();
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state_at(step: Step) -> WizardState {
let mut s = WizardState::new();
s.step = step;
s
}
#[test]
fn wizard_starts_on_welcome() {
let s = WizardState::new();
assert_eq!(s.step, Step::Welcome);
}
#[test]
fn welcome_advances_to_provider() {
let mut s = state_at(Step::Welcome);
s.next();
assert_eq!(s.step, Step::Provider);
}
#[test]
fn provider_advances_to_model_and_syncs() {
let mut s = state_at(Step::Provider);
s.provider_idx = 8; s.next();
assert_eq!(s.step, Step::Model);
assert_eq!(s.model_list, vec!["grok-3", "grok-3-mini"]);
}
#[test]
fn model_advances_to_theme() {
let mut s = state_at(Step::Model);
s.next();
assert_eq!(s.step, Step::Theme);
}
#[test]
fn theme_advances_to_network() {
let mut s = state_at(Step::Theme);
s.next();
assert_eq!(s.step, Step::Network);
}
#[test]
fn network_advances_to_summary() {
let mut s = state_at(Step::Network);
s.next();
assert_eq!(s.step, Step::Summary);
}
#[test]
fn summary_does_not_advance() {
let mut s = state_at(Step::Summary);
s.next();
assert_eq!(s.step, Step::Summary);
}
#[test]
fn back_goes_to_previous_step() {
let mut s = state_at(Step::Model);
s.prev();
assert_eq!(s.step, Step::Provider);
}
#[test]
fn back_on_welcome_stays() {
let mut s = state_at(Step::Welcome);
s.prev();
assert_eq!(s.step, Step::Welcome);
}
#[test]
fn move_up_clamps_at_zero() {
let mut s = state_at(Step::Provider);
s.provider_idx = 0;
s.move_up();
assert_eq!(s.provider_idx, 0);
}
#[test]
fn move_down_clamps_at_end() {
let mut s = state_at(Step::Provider);
s.provider_idx = s.provider_list.len() - 1;
s.move_down();
assert_eq!(s.provider_idx, s.provider_list.len() - 1);
}
#[test]
fn apply_selections_populates_config() {
let mut s = WizardState::new();
s.provider_idx = 9; s.sync_models();
s.model_idx = 0;
s.theme_idx = 1; s.network_idx = 0; s.apply_selections();
assert_eq!(s.config.provider, Provider::Ollama);
assert_eq!(s.config.default_model, "llama3.2");
assert_eq!(s.config.theme, Theme::Light);
assert_eq!(s.config.default_network, "local");
}
#[test]
fn sync_models_preserves_index_when_in_bounds() {
let mut s = state_at(Step::Provider);
s.provider_idx = 0; s.model_idx = 1;
s.sync_models();
assert_eq!(s.model_idx, 1);
}
#[test]
fn sync_models_clamps_index_when_out_of_bounds() {
let mut s = state_at(Step::Provider);
s.provider_idx = 0; s.model_idx = 10;
s.sync_models();
assert_eq!(s.model_idx, 0);
}
#[test]
fn next_clears_error() {
let mut s = state_at(Step::Summary);
s.error = Some("previous error".to_string());
s.step = Step::Network;
s.next();
assert!(s.error.is_none());
}
}