use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::types::{Activity, ApproveMode, Role};
use crate::utils::{truncate, fmt_tokens, progress_bar, word_wrap};
use crate::markdown::render_markdown;
use crate::app::TuiApp;
use crate::SPINNER;
impl TuiApp {
pub(crate) fn draw(&self, f: &mut ratatui::Frame) {
let queue_height = if self.pending_messages.is_empty() {
Constraint::Length(0)
} else {
Constraint::Length(1)
};
let input_lines = self.input.lines().count().max(1);
let input_height = if input_lines <= 1 {
Constraint::Length(1)
} else {
Constraint::Length(input_lines.min(5) as u16 + 1) };
let constraints = vec![
Constraint::Length(1), Constraint::Min(3), queue_height, Constraint::Length(1), input_height, ];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.area());
self.msg_area_top.set(chunks[1].y);
self.draw_status(f, chunks[0]);
self.draw_messages(f, chunks[1]);
if !self.pending_messages.is_empty() {
self.draw_queue(f, chunks[2]);
}
self.draw_usage(f, chunks[3]);
self.draw_input(f, chunks[4]);
}
fn draw_status(&self, f: &mut ratatui::Frame, area: Rect) {
let status_text = if self.activity == Activity::Idle {
" Ready "
} else {
" ... "
};
let status_color = if self.activity == Activity::Idle {
Color::Green
} else {
Color::Yellow
};
let spans = vec![
Span::styled(" MatrixCode ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("│", Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {} ", self.model), Style::default().fg(Color::White)),
Span::styled("│", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(" mode:{} ", self.approve_mode.label()),
Style::default().fg(match self.approve_mode {
ApproveMode::Ask => Color::Yellow,
ApproveMode::Auto => Color::Green,
ApproveMode::Strict => Color::Red,
})
),
Span::styled("│", Style::default().fg(Color::DarkGray)),
Span::styled(status_text, Style::default().fg(status_color)),
];
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_usage(&self, f: &mut ratatui::Frame, area: Rect) {
if self.tokens_in == 0 && self.tokens_out == 0 {
f.render_widget(Paragraph::new(Line::styled(
" /help │ PgUp/PgDn: scroll │ Home/End: top/bot │ Use terminal for text selection",
Style::default().fg(Color::DarkGray)
)), area);
return;
}
let context_pct = if self.context_size > 0 {
(self.tokens_in as f64 / self.context_size as f64 * 100.0).min(100.0)
} else { 0.0 };
let ctx_color = if context_pct < 50.0 { Color::Green }
else if context_pct < 75.0 { Color::Yellow }
else { Color::Red };
let bar = progress_bar(context_pct, 20);
let mut parts: Vec<Span> = vec![
Span::styled(
format!("in {} / out {} (session: {})",
fmt_tokens(self.tokens_in),
fmt_tokens(self.tokens_out),
fmt_tokens(self.session_total_out)
),
Style::default().fg(Color::Gray)
),
];
parts.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!("cache r/w {}/{}",
fmt_tokens(self.cache_read),
fmt_tokens(self.cache_created)
),
Style::default().fg(Color::Cyan)
));
if self.debug_mode {
parts.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!("api:{} ", self.api_calls),
Style::default().fg(Color::Magenta)
));
if self.tool_calls > 0 {
parts.push(Span::styled(
format!("tools:{} ", self.tool_calls),
Style::default().fg(Color::Blue)
));
}
if self.compressions > 0 {
parts.push(Span::styled(
format!("compress:{} ", self.compressions),
Style::default().fg(Color::Yellow)
));
}
}
parts.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
parts.push(Span::styled(
format!("ctx {} / {} ({:.1}%) {}",
fmt_tokens(self.tokens_in),
fmt_tokens(self.context_size),
context_pct,
bar
),
Style::default().fg(ctx_color)
));
f.render_widget(Paragraph::new(Line::from(parts)), area);
}
fn draw_messages(&self, f: &mut ratatui::Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
let max_w = area.width.saturating_sub(5) as usize;
let selection = self.selection.map(|s| s.normalized());
if self.show_welcome && self.messages.is_empty() {
lines.push(Line::styled(
"╭─────────────────────────────────────────────────────────────╮",
Style::default().fg(Color::Cyan)
));
lines.push(Line::styled(
"│ 🤖 MatrixCode │",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
));
lines.push(Line::styled(
"│ AI-powered coding assistant with extended thinking │",
Style::default().fg(Color::DarkGray)
));
lines.push(Line::raw("│ │"));
lines.push(Line::styled(
"│ Commands: /help /clear /history /mode /new /exit │",
Style::default().fg(Color::Gray)
));
lines.push(Line::styled(
"│ Shortcuts: Enter=send │ PgUp/PgDn=scroll │ Alt+T=thinking │",
Style::default().fg(Color::Gray)
));
lines.push(Line::styled(
"╰─────────────────────────────────────────────────────────────╯",
Style::default().fg(Color::Cyan)
));
lines.push(Line::raw(""));
}
for msg in &self.messages {
let icon = msg.role.icon();
let label = msg.role.label();
let color = msg.role.color();
let is_approval = msg.content.contains("APPROVAL REQUIRED");
if matches!(msg.role, Role::Thinking) {
lines.push(Line::from(vec![
Span::styled(" 💭 ", Style::default().fg(Color::DarkGray)),
Span::styled("Thinking", Style::default().fg(Color::DarkGray)),
]));
} else {
let header_color = if is_approval { Color::Red } else { color };
lines.push(Line::from(vec![
Span::styled(icon, Style::default().fg(header_color)),
Span::raw(" "),
Span::styled(label, Style::default().fg(header_color).add_modifier(Modifier::BOLD)),
]));
}
if matches!(msg.role, Role::Thinking) {
let md_lines = render_markdown(&msg.content, max_w.saturating_sub(4)); if self.thinking_collapsed {
for line in md_lines.iter().take(2) {
let indented = Line::styled(
format!(" {}", line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()),
Style::default().fg(Color::DarkGray)
);
lines.push(indented);
}
if md_lines.len() > 2 {
lines.push(Line::styled(
format!(" ... ({} more lines)", md_lines.len() - 2),
Style::default().fg(Color::DarkGray)
));
}
} else {
for line in md_lines {
let indented = Line::styled(
format!(" {}", line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()),
Style::default().fg(Color::DarkGray)
);
lines.push(indented);
}
}
} else {
if msg.role == Role::Assistant {
let md_lines = render_markdown(&msg.content, max_w);
lines.extend(md_lines);
} else if let Role::Tool { name, is_error } = &msg.role {
let summary = summarize_tool_result(name, &msg.content, is_error, max_w);
for line in summary {
lines.push(line);
}
} else {
let content_color = if is_approval { Color::Yellow } else { Color::White };
let wrapped = word_wrap(&msg.content, max_w);
for line in wrapped {
lines.push(Line::styled(
format!(" {}", line),
Style::default().fg(content_color)
));
}
}
}
lines.push(Line::raw(""));
}
if !self.thinking.is_empty() {
lines.push(Line::from(vec![
Span::styled("💭 ", Style::default().fg(Color::DarkGray)),
Span::styled("Thinking", Style::default().fg(Color::DarkGray)),
]));
let md_lines = render_markdown(&self.thinking, max_w.saturating_sub(4));
if self.thinking_collapsed {
for line in md_lines.iter().take(1) {
let indented = Line::styled(
format!(" {}", line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()),
Style::default().fg(Color::DarkGray)
);
lines.push(indented);
}
if md_lines.len() > 1 {
lines.push(Line::styled(
format!(" ... ({} more lines)", md_lines.len() - 1),
Style::default().fg(Color::DarkGray)
));
}
} else {
for line in md_lines {
let indented = Line::styled(
format!(" {}", line.spans.iter().map(|s| s.content.as_ref()).collect::<String>()),
Style::default().fg(Color::DarkGray)
);
lines.push(indented);
}
}
lines.push(Line::raw(""));
}
if !self.streaming.is_empty() {
let spinner = SPINNER[self.frame];
lines.push(Line::from(vec![
Span::styled("🤖", Style::default().fg(Color::Blue)),
Span::raw(" "),
Span::styled("Assistant", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {} ", spinner), Style::default().fg(self.activity.color())),
]));
let md_lines = render_markdown(&self.streaming, max_w);
lines.extend(md_lines);
lines.push(Line::styled(" ▌", Style::default().fg(Color::Cyan)));
}
let is_tool_activity = matches!(self.activity,
Activity::Reading | Activity::Writing | Activity::Editing |
Activity::Searching | Activity::Running | Activity::WebSearch |
Activity::WebFetch | Activity::Tool(_)
);
if self.activity == Activity::Thinking && self.streaming.is_empty() && self.thinking.is_empty() {
let spinner = SPINNER[self.frame];
lines.push(Line::from(vec![
Span::styled(spinner, Style::default().fg(self.activity.color())),
Span::raw(" "),
Span::styled(self.activity.label(), Style::default().fg(self.activity.color())),
Span::styled(" Waiting for AI response...", Style::default().fg(Color::DarkGray)),
]));
}
if is_tool_activity && self.streaming.is_empty() && self.thinking.is_empty() {
let mut spans = vec![
Span::styled(SPINNER[self.frame], Style::default().fg(self.activity.color())),
Span::raw(" "),
Span::styled(self.activity.label(), Style::default().fg(self.activity.color())),
];
if !self.activity_detail.is_empty() {
spans.push(Span::styled(
format!(" {}", self.activity_detail),
Style::default().fg(Color::DarkGray)
));
}
lines.push(Line::from(spans));
}
let total_lines = lines.len() as u16;
let visible_height = area.height;
let max_scroll = if total_lines > visible_height {
total_lines.saturating_sub(visible_height)
} else {
0
};
self.max_scroll.set(max_scroll);
let force_auto_scroll = (!self.streaming.is_empty()
|| !self.thinking.is_empty()
|| self.activity == Activity::Thinking)
&& !self.selecting;
let scroll_offset = if self.auto_scroll || force_auto_scroll {
max_scroll } else {
self.scroll_offset.min(max_scroll)
};
let highlighted_lines = if let Some(sel) = selection {
let sel_start = sel.start_line;
let sel_end = sel.end_line;
lines.into_iter().enumerate().map(|(i, line)| {
if i >= sel_start && i <= sel_end {
let content = line.spans.iter().map(|s| s.content.as_ref()).collect::<String>();
Line::styled(content, Style::default().fg(Color::White).bg(Color::DarkGray))
} else {
line
}
}).collect()
} else {
lines
};
f.render_widget(
Paragraph::new(highlighted_lines)
.scroll((scroll_offset, 0)),
area
);
}
fn draw_queue(&self, f: &mut ratatui::Frame, area: Rect) {
let mut spans: Vec<Span> = vec![
Span::styled("⏳ ", Style::default().fg(Color::Magenta)),
Span::styled(
format!("Queue ({}): ", self.pending_messages.len()),
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
),
];
for (i, msg) in self.pending_messages.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
}
let preview = msg.lines().next().unwrap_or("");
let truncated = truncate(preview, 30);
spans.push(Span::styled(
format!("\"{}\"", truncated),
Style::default().fg(Color::Yellow)
));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_input(&self, f: &mut ratatui::Frame, area: Rect) {
let prompt = match self.activity {
Activity::Idle => "❯ ",
Activity::Asking => "❓ ",
_ => "❯ ",
};
let prompt_color = match self.activity {
Activity::Idle => Color::Yellow,
Activity::Asking => Color::Yellow,
_ => Color::Gray,
};
let is_multiline = self.input.contains('\n');
let max_w = area.width as usize;
if !is_multiline {
let mut spans: Vec<Span> = vec![
Span::styled(prompt, Style::default().fg(prompt_color).add_modifier(Modifier::BOLD)),
];
if self.activity == Activity::Asking {
spans.push(Span::styled("[reply: y/n or option] ", Style::default().fg(Color::Yellow)));
}
if self.input.is_empty() {
spans.push(Span::styled("_", Style::default().fg(Color::Cyan)));
} else {
spans.push(Span::styled(truncate(&self.input, max_w - 25), Style::default().fg(Color::White)));
}
spans.push(Span::styled(" Shift+Enter↵", Style::default().fg(Color::DarkGray)));
f.render_widget(Paragraph::new(Line::from(spans)), area);
} else {
let mut lines: Vec<Line> = Vec::new();
let first_line = self.input.lines().next().unwrap_or("");
let first_spans: Vec<Span> = vec![
Span::styled(prompt, Style::default().fg(prompt_color).add_modifier(Modifier::BOLD)),
Span::styled(truncate(first_line, max_w - 5), Style::default().fg(Color::White)),
];
lines.push(Line::from(first_spans));
for line in self.input.lines().skip(1).take(area.height as usize - 2) {
lines.push(Line::styled(
format!(" {}", truncate(line, max_w - 5)),
Style::default().fg(Color::White)
));
}
let total_lines = self.input.lines().count();
if total_lines > area.height as usize - 1 {
lines.push(Line::styled(
format!(" ... ({}/{} lines shown)", area.height as usize - 2, total_lines),
Style::default().fg(Color::DarkGray)
));
}
if lines.len() < area.height as usize {
lines.push(Line::styled(
" Shift+Enter↵ for newline, Enter↵ to send",
Style::default().fg(Color::DarkGray)
));
}
f.render_widget(Paragraph::new(lines), area);
}
}
}
fn summarize_tool_result(name: &str, content: &str, is_error: &bool, max_w: usize) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let color = if *is_error { Color::Red } else { Color::Cyan };
let error_prefix = if *is_error { "❌ " } else { "" };
let tool_type = name.to_lowercase();
match tool_type {
t if t.contains("read") || t.contains("reading") => {
let line_count = content.lines().count();
if line_count <= 3 {
for line in content.lines().take(3) {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
} else {
for line in content.lines().take(2) {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
lines.push(Line::styled(
format!(" {}... ({}) lines total", error_prefix, line_count),
Style::default().fg(Color::DarkGray)
));
}
}
t if t.contains("edit") || t.contains("editing") => {
if *is_error {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(content, max_w - 4)),
Style::default().fg(Color::Red)
));
} else {
lines.push(Line::styled(
" ✓ Applied changes",
Style::default().fg(Color::Green)
));
for line in content.lines().take(3) {
let prefix = if line.starts_with('+') { "+" }
else if line.starts_with('-') { "-" }
else { " " };
let line_color = if line.starts_with('+') { Color::Green }
else if line.starts_with('-') { Color::Red }
else { Color::DarkGray };
lines.push(Line::styled(
format!(" {}{}", prefix, truncate(line, max_w - 4)),
Style::default().fg(line_color)
));
}
}
}
t if t.contains("search") || t.contains("glob") => {
let matches: Vec<&str> = content.lines().filter(|l| !l.is_empty()).take(10).collect();
let total = content.lines().filter(|l| !l.is_empty()).count();
lines.push(Line::styled(
format!(" {}{} matches{}", error_prefix, total, if total > 10 { format!(" (showing {})", matches.len()) } else { String::new() }),
Style::default().fg(color)
));
for m in matches.iter().take(5) {
lines.push(Line::styled(
format!(" {}", truncate(m, max_w - 6)),
Style::default().fg(Color::DarkGray)
));
}
}
t if t.contains("run") || t.contains("bash") => {
let line_count = content.lines().count();
if line_count <= 5 {
for line in content.lines() {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
} else {
for line in content.lines().take(2) {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
lines.push(Line::styled(
format!(" {}... ({}) lines ...", error_prefix, line_count - 4),
Style::default().fg(Color::DarkGray)
));
let last_lines: Vec<&str> = content.lines().rev().take(2).collect();
for line in last_lines.iter().rev() {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
}
}
t if t.contains("write") || t.contains("writing") => {
if *is_error {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(content, max_w - 4)),
Style::default().fg(Color::Red)
));
} else {
lines.push(Line::styled(
" ✓ File written successfully",
Style::default().fg(Color::Green)
));
}
}
t if t.contains("todo") => {
for line in content.lines() {
let trimmed = line.trim();
let (marker_color, content_color) = if trimmed.starts_with("[~]") {
(Color::Yellow, Color::Yellow)
} else if trimmed.starts_with("[x]") {
(Color::Green, Color::Green)
} else if trimmed.starts_with("[ ]") {
(Color::DarkGray, Color::Gray)
} else if trimmed.starts_with("Todos") {
(Color::Cyan, Color::Cyan)
} else {
(color, color)
};
if trimmed.starts_with("[") {
lines.push(Line::styled(
format!(" {}", truncate(line, max_w - 4)),
Style::default().fg(content_color)
));
} else {
lines.push(Line::styled(
format!(" {}", truncate(line, max_w - 4)),
Style::default().fg(marker_color)
));
}
}
}
t if t.contains("web") || t.contains("search") || t.contains("fetch") => {
let line_count = content.lines().count();
for line in content.lines().take(5) {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
if line_count > 5 {
lines.push(Line::styled(
format!(" {}... {} more results", error_prefix, line_count - 5),
Style::default().fg(Color::DarkGray)
));
}
}
_ => {
let line_count = content.lines().count();
if line_count <= 3 {
for line in content.lines() {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
} else {
for line in content.lines().take(2) {
lines.push(Line::styled(
format!(" {}{}", error_prefix, truncate(line, max_w - 4)),
Style::default().fg(color)
));
}
lines.push(Line::styled(
format!(" {}... ({}) lines total", error_prefix, line_count),
Style::default().fg(Color::DarkGray)
));
}
}
}
lines
}