use crate::app::App;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use regex::Regex;
pub const BANNER: &str = r#"
| | __ _ ______ __| | | | __ _ _ __ ___ __ _
| | / _` ||_ /\ \/ /| | | |/ _` | '_ ` _ \ / _` |
| |__| (_| | / / \ / | |___ | | (_| | | | | | | (_| |
|_____\__,_|/___| /_/ |_____||_|\__,_|_| |_| |_|\__,_|
"#;
pub fn ui(f: &mut Frame, app: &mut App) {
if app.debug_keys {
app.render_count = app.render_count.wrapping_add(1);
}
let root_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
f.render_widget(
Paragraph::new(BANNER)
.style(Style::default().fg(Color::Cyan))
.alignment(Alignment::Center),
root_layout[0],
);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(root_layout[1]);
let selected_model = app.list_state.selected()
.and_then(|i| app.models.get(i))
.cloned()
.unwrap_or_else(|| "None".to_string());
let items: Vec<ListItem> = app
.models
.iter()
.enumerate()
.map(|(i, m)| {
let is_selected = app.list_state.selected() == Some(i);
let history_len = app.model_histories.get(m).map(|h| h.len()).unwrap_or(0);
let display = if history_len > 0 {
format!("{} [{}]", m, if history_len > 1000 { "📝" } else { "📄" })
} else {
m.clone()
};
ListItem::new(display)
.style(if is_selected {
Style::default().fg(Color::Yellow)
} else {
Style::default()
})
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL)
.title(format!(" Models ({}) ", app.models.len())))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, main_chunks[0], &mut app.list_state);
let input_line_count = app.input.lines().count().max(1).min(5);
let input_height = (input_line_count + 2) as u16;
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(input_height)])
.split(main_chunks[1]);
let history_text = parse_history(&app.history);
let visible_height = chat_chunks[0].height.saturating_sub(2);
let visible_width = chat_chunks[0].width.saturating_sub(2) as usize;
let total_lines: u16 = if visible_width == 0 {
history_text.height() as u16
} else {
history_text
.lines
.iter()
.map(|line| {
let line_width: usize = line
.spans
.iter()
.map(|s| s.content.chars().count())
.sum();
if line_width == 0 {
1u16
} else {
((line_width + visible_width - 1) / visible_width) as u16
}
})
.sum()
};
if app.autoscroll {
app.scroll = total_lines.saturating_sub(visible_height);
} else {
let max_scroll = total_lines.saturating_sub(visible_height);
if app.scroll > max_scroll {
app.scroll = max_scroll;
}
}
let scroll_status = if app.autoscroll {
" [AUTOSCROLL] "
} else {
" [MANUAL SCROLL 🔒] "
};
f.render_widget(Clear, chat_chunks[0]);
f.render_widget(
Paragraph::new(history_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Conversation History{} ", scroll_status))
.border_style(if !app.autoscroll {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
)
.wrap(Wrap { trim: true })
.scroll((app.scroll, 0)),
chat_chunks[0],
);
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame_idx = (app.start_time.elapsed().as_millis() / 100) as usize % spinner_frames.len();
let input_title = if app.is_loading {
format!(" {} AI is thinking... ", spinner_frames[frame_idx])
} else {
" > Input ".into()
};
let input_chars: Vec<char> = app.input.chars().collect();
let cursor_pos = app.cursor_pos.min(input_chars.len());
let selection_range = app.get_selection_range();
let cursor_style = Style::default().add_modifier(Modifier::REVERSED);
let selection_style = Style::default()
.bg(Color::Blue)
.fg(Color::White);
let cursor_line = input_chars.iter()
.take(cursor_pos)
.filter(|&&c| c == '\n')
.count();
let mut lines = Vec::new();
let mut current_line_spans = Vec::new();
let mut char_index = 0;
for ch in input_chars.iter().chain(std::iter::once(&'\0')) {
let is_newline = *ch == '\n';
let is_end = *ch == '\0';
if !is_newline && !is_end {
let mut style = Style::default();
let mut is_cursor = false;
if char_index == cursor_pos && app.cursor_visible {
style = cursor_style;
is_cursor = true;
}
if let Some((sel_start, sel_end)) = selection_range {
if char_index >= sel_start && char_index < sel_end {
style = selection_style;
if char_index == cursor_pos && app.cursor_visible {
style = selection_style.add_modifier(Modifier::REVERSED);
}
}
}
if is_cursor || style.bg.is_some() || style.add_modifier != Modifier::empty() {
current_line_spans.push(Span::styled(ch.to_string(), style));
} else {
current_line_spans.push(Span::raw(ch.to_string()));
}
char_index += 1;
} else if is_newline {
if current_line_spans.is_empty() {
current_line_spans.push(Span::raw(" "));
}
lines.push(Line::from(current_line_spans.clone()));
current_line_spans.clear();
char_index += 1;
} else {
if char_index == cursor_pos {
if app.cursor_visible {
current_line_spans.push(Span::styled(" ", cursor_style));
} else {
current_line_spans.push(Span::raw(" "));
}
}
if !current_line_spans.is_empty() {
lines.push(Line::from(current_line_spans));
} else if lines.is_empty() {
lines.push(Line::from(vec![Span::raw(" ")]));
}
break;
}
}
let visible_input_height = input_height.saturating_sub(2) as usize; let total_input_lines = lines.len();
let mut scroll_offset = app.input_scroll;
if cursor_line < scroll_offset as usize {
scroll_offset = cursor_line as u16;
} else if cursor_line >= (scroll_offset as usize + visible_input_height) {
scroll_offset = (cursor_line + 1).saturating_sub(visible_input_height) as u16;
}
let max_input_scroll = total_input_lines.saturating_sub(visible_input_height);
scroll_offset = scroll_offset.min(max_input_scroll as u16);
let input_text = Text::from(lines);
f.render_widget(
Paragraph::new(input_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(input_title)
.border_style(if app.is_loading {
Style::default().fg(Color::Yellow)
} else {
Style::default()
}),
)
.wrap(Wrap { trim: false })
.scroll((scroll_offset, 0)),
chat_chunks[1],
);
let mut status = format!(
" C-q: Quit | C-S-c: Copy | C-S-v: Paste | S-Enter: Newline | PgUp/Dn: Scroll | C-↑↓: Model [{}] ",
selected_model
);
if app.debug_keys {
let max_scroll = total_lines.saturating_sub(visible_height);
let last_key = app.debug_last_key.as_deref().unwrap_or("-");
status.push_str(&format!(
"| Scroll: {}/{} | Render: {} | Key: {} ",
app.scroll, max_scroll, app.render_count, last_key
));
}
f.render_widget(
Paragraph::new(status).style(Style::default().bg(Color::White).fg(Color::Black)),
root_layout[2],
);
}
pub fn parse_history<'a>(history: &'a str) -> Text<'a> {
let code_block_re = Regex::new(r"(?s)```(?P<lang>\w+)?\n(?P<code>.*?)```").unwrap();
let mut text = Text::default();
let mut last_match_end = 0;
for caps in code_block_re.captures_iter(history) {
let full_match = caps.get(0).unwrap();
if full_match.start() > last_match_end {
process_styled_text(&history[last_match_end..full_match.start()], &mut text);
}
let lang = caps.name("lang").map_or("code", |m| m.as_str());
let code_content = caps.name("code").map_or("", |m| m.as_str());
text.push_line(Line::from(Span::styled(
format!(" ┌── {} ──", lang),
Style::default().fg(Color::Yellow),
)));
for line in code_content.lines() {
text.push_line(Line::from(vec![
Span::styled(" │ ", Style::default().fg(Color::Yellow)),
Span::raw(line),
]));
}
text.push_line(Line::from(Span::styled(
" └──────────",
Style::default().fg(Color::Yellow),
)));
last_match_end = full_match.end();
}
if last_match_end < history.len() {
process_styled_text(&history[last_match_end..], &mut text);
}
text
}
pub fn process_styled_text<'a>(text: &'a str, target: &mut Text<'a>) {
for line in text.lines() {
let trimmed = line.trim();
let mut spans = Vec::new();
if trimmed.starts_with("###") {
spans.push(Span::styled(
format!("● {}", trimmed.trim_start_matches('#').trim()),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
} else if line.starts_with("YOU:") {
spans.push(Span::styled(
"YOU:",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(&line[4..]));
} else if line.starts_with("AI:") {
spans.push(Span::styled(
"AI: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(&line[3..]));
} else {
spans.push(Span::raw(line));
}
target.push_line(Line::from(spans));
}
}