use crate::app::App;
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
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;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
pub const BANNER: &str = r#"
| | __ _ ______ __| | | | __ _ _ __ ___ __ _
| | / _` ||_ /\ \/ /| | | |/ _` | '_ ` _ \ / _` |
| |__| (_| | / / \ / | |___ | | (_| | | | | | | (_| |
|_____\__,_|/___| /_/ |_____||_|\__,_|_| |_| |_|\__,_|
"#;
fn syntect_color_to_ratatui(color: syntect::highlighting::Color) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
fn map_language_tag(lang: &str) -> &str {
match lang.to_lowercase().as_str() {
"javascript" | "js" => "JavaScript",
"typescript" | "ts" => "TypeScript",
"jsx" => "JavaScript (Babel)",
"tsx" => "TypeScript",
"python" | "py" => "Python",
"rust" | "rs" => "Rust",
"c" => "C",
"cpp" | "c++" | "cxx" => "C++",
"csharp" | "cs" | "c#" => "C#",
"java" => "Java",
"kotlin" | "kt" => "Kotlin",
"scala" => "Scala",
"groovy" => "Groovy",
"go" | "golang" => "Go",
"swift" => "Swift",
"objective-c" | "objc" => "Objective-C",
"objective-c++" | "objc++" => "Objective-C++",
"ruby" | "rb" => "Ruby",
"php" => "PHP",
"perl" | "pl" => "Perl",
"lua" => "Lua",
"r" => "R",
"bash" | "sh" | "shell" => "Bourne Again Shell (bash)",
"zsh" => "Bourne Again Shell (bash)", "fish" => "Bourne Again Shell (bash)",
"powershell" | "ps1" => "PowerShell",
"html" | "htm" => "HTML",
"css" => "CSS",
"scss" | "sass" => "SCSS",
"less" => "LESS",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"toml" => "TOML",
"xml" => "XML",
"csv" => "CSV",
"markdown" | "md" => "Markdown",
"latex" | "tex" => "LaTeX",
"rst" | "restructuredtext" => "reStructuredText",
"sql" | "mysql" | "postgresql" | "postgres" => "SQL",
"haskell" | "hs" => "Haskell",
"ocaml" | "ml" => "OCaml",
"erlang" | "erl" => "Erlang",
"elixir" | "ex" | "exs" => "Elixir",
"clojure" | "clj" => "Clojure",
"fsharp" | "fs" | "f#" => "F#",
"lisp" => "Lisp",
"scheme" => "Scheme",
"dart" => "Dart",
"julia" | "jl" => "Julia",
"zig" => "Zig",
"nim" => "Nim",
"crystal" => "Crystal",
"d" => "D",
"v" | "vlang" => "V",
"ini" | "cfg" => "INI",
"env" | "dotenv" => "Shell Script (Bash)", "dockerfile" | "docker" => "Dockerfile",
"makefile" | "make" => "Makefile",
_ => lang,
}
}
fn highlight_code_block(code: &str, language: &str, theme_name: &str) -> Vec<Line<'static>> {
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();
let mapped_lang = map_language_tag(language);
let syntax = ps.find_syntax_by_name(mapped_lang)
.or_else(|| ps.find_syntax_by_extension(mapped_lang))
.or_else(|| ps.find_syntax_by_extension(language))
.unwrap_or_else(|| ps.find_syntax_plain_text());
let theme = ts.themes.get(theme_name)
.unwrap_or_else(|| &ts.themes["base16-ocean.dark"]);
let mut highlighter = HighlightLines::new(syntax, theme);
let mut lines = Vec::new();
for line in LinesWithEndings::from(code) {
let ranges = highlighter.highlight_line(line, &ps).unwrap_or_default();
let mut spans = vec![
Span::styled(" │ ".to_string(), Style::default().fg(Color::Yellow))
];
for (style, text) in ranges {
let fg_color = syntect_color_to_ratatui(style.foreground);
let mut ratatui_style = Style::default().fg(fg_color);
if style.font_style.contains(syntect::highlighting::FontStyle::BOLD) {
ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
}
spans.push(Span::styled(text.to_string(), ratatui_style));
}
lines.push(Line::from(spans));
}
lines
}
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, app.settings.syntax_theme.as_str());
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-o: Settings | 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],
);
if app.show_settings_dialog {
render_settings_dialog(f, app);
}
}
fn render_settings_dialog(f: &mut Frame, app: &App) {
let area = f.area();
let popup_width = (area.width * 60) / 100;
let popup_height = (area.height * 70) / 100;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = ratatui::layout::Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
f.render_widget(Clear, popup_area);
let themes = crate::app::SyntaxTheme::all();
let mut items: Vec<ListItem> = Vec::new();
items.push(ListItem::new(Line::from(vec![
Span::styled("Settings", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
])));
items.push(ListItem::new(""));
items.push(ListItem::new(Line::from(vec![
Span::styled("Syntax Highlighting Theme:", Style::default().fg(Color::Yellow)),
])));
items.push(ListItem::new(""));
items.push(ListItem::new(Line::from(vec![
Span::styled(" Dark Themes:", Style::default().fg(Color::Gray)),
])));
for (idx, theme) in themes.iter().enumerate() {
if !theme.is_dark() {
break;
}
let is_selected = idx == app.settings_selection;
let is_current = theme == &app.settings.syntax_theme;
let mut label = format!(" {}", theme.display_name());
if is_current {
label.push_str(" ✓");
}
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default()
};
items.push(ListItem::new(label).style(style));
}
items.push(ListItem::new(""));
items.push(ListItem::new(Line::from(vec![
Span::styled(" Light Themes:", Style::default().fg(Color::Gray)),
])));
for (idx, theme) in themes.iter().enumerate() {
if theme.is_dark() {
continue;
}
let is_selected = idx == app.settings_selection;
let is_current = theme == &app.settings.syntax_theme;
let mut label = format!(" {}", theme.display_name());
if is_current {
label.push_str(" ✓");
}
let style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default()
};
items.push(ListItem::new(label).style(style));
}
items.push(ListItem::new(""));
items.push(ListItem::new(Line::from(vec![
Span::styled("↑↓: Navigate Enter: Apply Esc/q: Close",
Style::default().fg(Color::DarkGray)),
])));
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Settings ")
);
f.render_widget(list, popup_area);
}
pub fn parse_history<'a>(history: &'a str, theme_name: &str) -> Text<'a> {
let code_block_re = Regex::new(r"(?s)```(?P<lang>\w+)?\s*\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),
)));
let highlighted_lines = highlight_code_block(code_content, lang, theme_name);
for line in highlighted_lines {
text.push_line(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() {
if line.starts_with("YOU:") {
let rest = if line.len() > 4 { &line[4..] } else { "" };
let mut spans: Vec<Span<'static>> = vec![
Span::styled(
"YOU:".to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
];
if !rest.is_empty() {
spans.push(Span::raw(rest.to_string()));
}
target.push_line(Line::from(spans));
continue;
} else if line.starts_with("AI:") {
let rest = if line.len() > 3 { &line[3..] } else { "" };
let mut spans: Vec<Span<'static>> = vec![
Span::styled(
"AI: ".to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
];
if !rest.is_empty() {
spans.push(Span::raw(rest.to_string()));
}
target.push_line(Line::from(spans));
continue;
}
let mut spans: Vec<Span<'static>> = Vec::new();
parse_line_markdown(line, &mut spans);
target.push_line(Line::from(spans));
}
}
fn parse_line_markdown(line: &str, spans: &mut Vec<Span<'static>>) {
let trimmed = line.trim();
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
spans.push(Span::styled(
"─".repeat(40),
Style::default().fg(Color::DarkGray),
));
return;
}
if let Some(header_text) = trimmed.strip_prefix("######").map(|s| (6, s))
.or_else(|| trimmed.strip_prefix("#####").map(|s| (5, s)))
.or_else(|| trimmed.strip_prefix("####").map(|s| (4, s)))
.or_else(|| trimmed.strip_prefix("###").map(|s| (3, s)))
.or_else(|| trimmed.strip_prefix("##").map(|s| (2, s)))
.or_else(|| trimmed.strip_prefix("#").map(|s| (1, s)))
{
let (_level, text) = header_text;
let bullet = "● ";
spans.push(Span::styled(
format!("{}{}", bullet, text.trim()),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
return;
}
if let Some(quote_text) = trimmed.strip_prefix(">") {
spans.push(Span::styled(
"│ ".to_string(),
Style::default().fg(Color::Green),
));
parse_inline_markdown(quote_text.trim(), spans, Style::default().fg(Color::Green).add_modifier(Modifier::ITALIC));
return;
}
if let Some(list_text) = trimmed.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
if list_text.trim().starts_with("[ ]") {
spans.push(Span::styled("☐ ".to_string(), Style::default().fg(Color::Yellow)));
parse_inline_markdown(&list_text.trim()[3..].trim(), spans, Style::default());
} else if list_text.trim().starts_with("[x]") || list_text.trim().starts_with("[X]") {
spans.push(Span::styled("☑ ".to_string(), Style::default().fg(Color::Green)));
parse_inline_markdown(&list_text.trim()[3..].trim(), spans, Style::default().add_modifier(Modifier::DIM));
} else {
spans.push(Span::styled("• ".to_string(), Style::default().fg(Color::Cyan)));
parse_inline_markdown(list_text, spans, Style::default());
}
return;
}
if let Some(caps) = Regex::new(r"^(\d+)\.\s+(.*)$").unwrap().captures(trimmed) {
let prefix = format!("{}. ", &caps[1]);
let rest = caps[2].to_string();
spans.push(Span::styled(
prefix,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
parse_inline_markdown(&rest, spans, Style::default());
return;
}
parse_inline_markdown(line, spans, Style::default());
}
fn parse_inline_markdown(text: &str, spans: &mut Vec<Span<'static>>, base_style: Style) {
let parser = Parser::new(text);
let mut current_style = base_style;
let mut style_stack = Vec::new();
for event in parser {
match event {
Event::Text(t) => {
spans.push(Span::styled(t.to_string(), current_style));
}
Event::Code(code) => {
spans.push(Span::styled(
format!("`{}`", code),
base_style.fg(Color::Yellow).add_modifier(Modifier::DIM),
));
}
Event::Start(tag) => {
style_stack.push(current_style);
current_style = match tag {
Tag::Strong => current_style.add_modifier(Modifier::BOLD),
Tag::Emphasis => current_style.add_modifier(Modifier::ITALIC),
Tag::Strikethrough => current_style.add_modifier(Modifier::CROSSED_OUT),
Tag::Link { .. } => current_style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED),
_ => current_style,
};
}
Event::End(tag_end) => {
if let Some(previous_style) = style_stack.pop() {
match tag_end {
TagEnd::Link => {
}
_ => {}
}
current_style = previous_style;
}
}
Event::SoftBreak | Event::HardBreak => {
}
_ => {
}
}
}
}