use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
prelude::{Alignment, Constraint::Max, Position, Stylize},
style::{Color, Modifier, Style, palette::tailwind},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs, Wrap},
};
use strum::IntoEnumIterator;
use strum_macros::{Display as DeriveDisplay, EnumIter as DeriveEnumIter};
use tui_input::{Input, backend::crossterm::EventHandler as InputHandler};
use crate::chat_compl::{ChatCompletionsRequestBuilder, Message};
use crate::client::XaiClient;
use crate::list_mod::ReducedModelListRequestBuilder;
use crate::traits::{ChatCompletionsFetcher, ClientConfig, ListModelFetcher};
use std::env;
use std::io;
#[derive(Debug, Clone, DeriveEnumIter, DeriveDisplay, PartialEq)]
enum Tab {
#[strum(to_string = "🔐 Settings")]
Settings,
#[strum(to_string = "💬 Chat")]
Chat,
#[strum(to_string = "📜 History")]
History,
#[strum(to_string = "⚙️ Info")]
Info,
}
impl Tab {
fn next(self) -> Self {
let mut iter = Tab::iter().cycle();
while let Some(tab) = iter.next() {
if tab == self {
return iter.next().unwrap_or(self);
}
}
self
}
fn previous(self) -> Self {
let tabs: Vec<_> = Tab::iter().collect();
let idx = tabs.iter().position(|t| *t == self).unwrap_or(0);
if idx == 0 {
tabs.last().cloned().unwrap_or(self)
} else {
tabs[idx - 1].clone()
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum InputMode {
Normal,
Editing,
}
#[derive(Debug, Clone, PartialEq)]
enum CurrentInput {
ApiKey,
SelectedModel,
ChatInput,
}
struct App {
input_mode: InputMode,
selected_tab: Tab,
api_key: Input,
selected_model: Input,
user_input: Input,
client: Option<XaiClient>,
chat_history: Vec<Line<'static>>,
current_input: Option<CurrentInput>,
scroll_chat: u16,
info_lines: Vec<Line<'static>>,
}
impl Default for App {
fn default() -> Self {
let api_key_val = env::var("XAI_API_KEY").unwrap_or_default();
Self {
input_mode: InputMode::Normal,
selected_tab: Tab::Settings,
api_key: Input::new(api_key_val),
selected_model: Input::new("grok-4".to_string()),
user_input: Input::default(),
chat_history: vec![],
current_input: None,
client: None,
scroll_chat: 0,
info_lines: vec![],
}
}
}
pub async fn run_tui() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(e) = res {
eprintln!("{:?}", e);
}
Ok(())
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let mut app = App::default();
if !app.api_key.value().is_empty()
&& let Ok(client) = XaiClient::builder().build()
{
client.set_api_key(app.api_key.value().to_string());
app.client = Some(client);
}
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
match app.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('q') => break,
KeyCode::Char('e') => {
app.input_mode = InputMode::Editing;
match app.selected_tab {
Tab::Settings => app.current_input = Some(CurrentInput::ApiKey),
Tab::Chat => app.current_input = Some(CurrentInput::ChatInput),
_ => app.current_input = None,
}
}
KeyCode::Right | KeyCode::Char('d') => {
app.selected_tab = app.selected_tab.next()
}
KeyCode::Left | KeyCode::Char('a') => {
app.selected_tab = app.selected_tab.previous()
}
KeyCode::Up if app.scroll_chat > 0 => app.scroll_chat -= 1,
KeyCode::Down => app.scroll_chat += 1,
KeyCode::PageUp => app.scroll_chat = app.scroll_chat.saturating_sub(5),
KeyCode::PageDown => app.scroll_chat += 5,
_ => {}
},
InputMode::Editing => match key.code {
KeyCode::Esc => app.input_mode = InputMode::Normal,
KeyCode::Enter => match app.selected_tab {
Tab::Settings => {
let api_key = app.api_key.value().to_string();
match XaiClient::builder().build() {
Ok(client) => {
client.set_api_key(api_key);
app.client = Some(client);
app.input_mode = InputMode::Normal;
}
Err(e) => {
app.chat_history.push(
Line::from(format!("Error building client: {e}"))
.style(Style::default().fg(Color::Red)),
);
}
}
}
Tab::Chat => {
app.scroll_chat = 0;
let msg = app.user_input.value().to_string();
if msg.trim().is_empty() {
continue;
}
app.chat_history.push(
Line::from(vec![Span::styled(
format!("🧑 You: {}", msg),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)])
.alignment(Alignment::Right),
);
if let Some(ref client) = app.client {
let builder = ChatCompletionsRequestBuilder::new(
client.clone(),
app.selected_model.value().to_string(),
vec![Message::text("user", msg.clone())],
);
match builder.clone().build() {
Ok(req) => match builder.create_chat_completion(req).await {
Ok(resp) => {
let content = resp
.choices
.first()
.map(|c| c.message.content.to_string())
.unwrap_or_default();
app.chat_history.push(
Line::from(vec![
Span::styled(
"🤖 Grok: ",
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(content),
])
.alignment(Alignment::Left),
);
}
Err(e) => {
app.chat_history.push(
Line::from(format!("Error: {e}"))
.style(Style::default().fg(Color::Red)),
);
}
},
Err(e) => {
app.chat_history.push(
Line::from(format!("Request error: {e}"))
.style(Style::default().fg(Color::Red)),
);
}
}
} else {
app.chat_history.push(
Line::from(
"⚠ No client configured. Go to Settings and enter your API key.",
)
.style(Style::default().fg(Color::Yellow)),
);
}
app.user_input.reset();
}
Tab::Info => {
if let Some(ref client) = app.client {
let builder = ReducedModelListRequestBuilder::new(client.clone());
match builder.fetch_model_info().await {
Ok(resp) => {
app.info_lines.clear();
app.info_lines.push(
Line::from("Available Models:").style(
Style::default().add_modifier(Modifier::BOLD),
),
);
for m in resp.data {
app.info_lines.push(Line::from(format!(
" • {} (owned by: {})",
m.id, m.owned_by
)));
}
}
Err(e) => {
app.info_lines = vec![
Line::from(format!("Error loading models: {e}"))
.style(Style::default().fg(Color::Red)),
];
}
}
} else {
app.info_lines = vec![
Line::from("⚠ No client configured. Go to Settings first.")
.style(Style::default().fg(Color::Yellow)),
];
}
app.input_mode = InputMode::Normal;
}
_ => {}
},
KeyCode::Tab => match app.current_input {
Some(CurrentInput::ApiKey) => {
app.current_input = Some(CurrentInput::SelectedModel);
}
Some(CurrentInput::SelectedModel) => {
app.current_input = Some(CurrentInput::ApiKey);
}
_ => {}
},
_ => match app.selected_tab {
Tab::Settings => match app.current_input {
Some(CurrentInput::ApiKey) => {
app.api_key.handle_event(&Event::Key(key));
}
Some(CurrentInput::SelectedModel) => {
app.selected_model.handle_event(&Event::Key(key));
}
_ => {}
},
Tab::Chat => {
let _ = app.user_input.handle_event(&Event::Key(key));
}
_ => {}
},
},
}
}
}
Ok(())
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.area());
let titles = Tab::iter().map(|t| {
Line::from(vec![Span::styled(
t.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)])
});
let tabs = Tabs::new(titles.collect::<Vec<_>>())
.select(app.selected_tab.clone() as usize)
.block(Block::default().borders(Borders::ALL).title(" ✨ X-AI "))
.highlight_style(Style::default().fg(Color::Yellow));
f.render_widget(tabs, chunks[0]);
match app.selected_tab {
Tab::Settings => render_settings(f, app, chunks[1]),
Tab::Chat => render_chat(f, app, chunks[1]),
Tab::History => {
let para = Paragraph::new(app.chat_history.clone())
.block(
Block::default()
.borders(Borders::ALL)
.title("📜 Full History"),
)
.wrap(Wrap { trim: true });
f.render_widget(para, chunks[1]);
}
Tab::Info => render_info(f, app, chunks[1]),
}
let footer_layout = Layout::new(Direction::Vertical, [Max(1), Max(1), Max(1)]).split(chunks[2]);
let keys = Line::raw(
"◄/► or a/d: tabs | e: edit | Tab: next field | Enter: confirm | Esc: cancel | q: quit",
)
.centered();
let credit = Line::raw("© Wise AI Foundation - xAI Grok SDK")
.fg(Color::LightGreen)
.bg(tailwind::SLATE.c700)
.bold()
.centered();
f.render_widget(keys, footer_layout[0]);
f.render_widget(credit, footer_layout[2]);
}
fn render_settings(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
let scroll_key = app.api_key.visual_scroll(layout[0].width as usize);
let scroll_mod = app.selected_model.visual_scroll(layout[1].width as usize);
let key_active = matches!(app.current_input, Some(CurrentInput::ApiKey))
&& app.input_mode == InputMode::Editing;
let mod_active = matches!(app.current_input, Some(CurrentInput::SelectedModel))
&& app.input_mode == InputMode::Editing;
let api_widget = Paragraph::new(app.api_key.value())
.style(if key_active {
Style::default().fg(Color::Green)
} else {
Style::default()
})
.scroll((0, scroll_key as u16))
.block(
Block::default()
.borders(Borders::ALL)
.title("🔑 xAI API Key"),
);
let model_widget = Paragraph::new(app.selected_model.value())
.style(if mod_active {
Style::default().fg(Color::Green)
} else {
Style::default()
})
.scroll((0, scroll_mod as u16))
.block(
Block::default()
.borders(Borders::ALL)
.title("🤖 Model (default: grok-4)"),
);
f.render_widget(api_widget, layout[0]);
f.render_widget(model_widget, layout[1]);
let hint = Paragraph::new(if app.client.is_some() {
"✅ Client configured. Press ◄ / ► to switch to Chat."
} else {
"Enter your API key and press Enter to save."
})
.block(Block::default().borders(Borders::ALL));
f.render_widget(hint, layout[2]);
if app.input_mode == InputMode::Editing {
match app.current_input {
Some(CurrentInput::ApiKey) => {
let x = layout[0].x
+ ((app.api_key.visual_cursor()).max(scroll_key) - scroll_key) as u16
+ 1;
f.set_cursor_position(Position::new(x, layout[0].y + 1));
}
Some(CurrentInput::SelectedModel) => {
let x = layout[1].x
+ ((app.selected_model.visual_cursor()).max(scroll_mod) - scroll_mod) as u16
+ 1;
f.set_cursor_position(Position::new(x, layout[1].y + 1));
}
_ => {}
}
}
}
fn render_chat(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(area);
let chat = Paragraph::new(app.chat_history.clone())
.block(
Block::default()
.borders(Borders::ALL)
.title("💬 Conversation"),
)
.wrap(Wrap { trim: true })
.scroll((app.scroll_chat, 0));
f.render_widget(chat, layout[0]);
let scroll_input = app.user_input.visual_scroll(layout[1].width as usize);
let input_active = matches!(app.current_input, Some(CurrentInput::ChatInput))
&& app.input_mode == InputMode::Editing;
let input = Paragraph::new(app.user_input.value())
.style(if input_active {
Style::default().fg(Color::Green)
} else {
Style::default()
})
.scroll((0, scroll_input as u16))
.block(
Block::default()
.borders(Borders::ALL)
.title("Type your message (e → edit, Enter → send)"),
);
f.render_widget(input, layout[1]);
if input_active {
let x = layout[1].x
+ ((app.user_input.visual_cursor()).max(scroll_input) - scroll_input) as u16
+ 1;
f.set_cursor_position(Position::new(x, layout[1].y + 1));
}
}
fn render_info(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
let content = if app.info_lines.is_empty() {
vec![Line::from("Press e → Enter to load available models.")]
} else {
app.info_lines.clone()
};
let para = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.title("⚙️ Model Info"),
)
.wrap(Wrap { trim: true });
f.render_widget(para, area);
}