//! TUI rendering: frame layout, conversation display, and scroll management. use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; use super::{AppState, Mode}; use crate::core::types::Role; /// Estimate the total rendered line count for all messages and update `state.scroll` /// so the bottom of the content is visible. /// /// When content fits within the viewport, `state.scroll` is set to 0. /// /// Line estimation per message: /// - 1 line for the role header /// - `ceil(chars / width).max(1)` lines per newline-separated content line /// - 1 blank separator line pub(super) fn update_scroll(state: &mut AppState, area: Rect) { // 4 = input pane (3: border top + content + border bottom) + status bar (1) let viewport_height = area.height.saturating_sub(4); state.viewport_height = viewport_height; let width = area.width.max(1) as usize; let mut total_lines: u16 = 0; for (_, content) in &state.messages { total_lines = total_lines.saturating_add(1); // role header for line in content.lines() { let chars = line.chars().count(); let wrapped = chars.div_ceil(width).max(1) as u16; total_lines = total_lines.saturating_add(wrapped); } total_lines = total_lines.saturating_add(1); // blank separator } let max_scroll = total_lines.saturating_sub(viewport_height); match state.mode { // In Insert mode, auto-follow the bottom of the conversation. Mode::Insert => { state.scroll = max_scroll; } // In Normal/Command mode, the user controls scroll -- just clamp to bounds. _ => { state.scroll = state.scroll.min(max_scroll); } } } /// Compute the overlay rectangle for the command palette, centered on `output_area`. fn command_overlay_rect(output_area: Rect) -> Rect { let overlay_w = (output_area.width / 2).max(80).min(output_area.width); let overlay_h: u16 = 3; // border + content + border let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2; let overlay_y = output_area.y + 2; Rect { x: overlay_x, y: overlay_y, width: overlay_w, height: overlay_h.min(output_area.height), } } /// Render the full TUI into `frame`. /// /// Layout (top to bottom): /// ```text /// +--------------------------------+ /// | conversation history | Fill(1) /// | | /// +--------------------------------+ /// | Input | Length(3) /// | > _ | /// +--------------------------------+ /// | NORMAL tokens: -- | Length(1) /// +--------------------------------+ /// ``` /// /// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green. /// In Command mode, a one-line overlay appears at row 1 of the output pane. pub(super) fn render(frame: &mut Frame, state: &AppState) { let chunks = Layout::vertical([ Constraint::Fill(1), Constraint::Length(3), Constraint::Length(1), ]) .split(frame.area()); // --- Output pane --- let mut lines: Vec = Vec::new(); for (role, content) in &state.messages { let (label, color) = match role { Role::User => ("You:", Color::Cyan), Role::Assistant => ("Assistant:", Color::Green), }; lines.push(Line::from(Span::styled(label, Style::default().fg(color)))); for body_line in content.lines() { lines.push(Line::from(body_line.to_string())); } lines.push(Line::from("")); // blank separator } let output = Paragraph::new(lines) .wrap(Wrap { trim: false }) .scroll((state.scroll, 0)); let output_area = chunks[0]; frame.render_widget(output, output_area); // --- Tool approval overlay --- if let Some(ref approval) = state.pending_approval { let overlay_w = (output_area.width / 2).max(60).min(output_area.width); let overlay_h: u16 = 5; let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2; let overlay_y = output_area.y + output_area.height.saturating_sub(overlay_h) / 2; let overlay_area = Rect { x: overlay_x, y: overlay_y, width: overlay_w, height: overlay_h.min(output_area.height), }; frame.render_widget(Clear, overlay_area); let text = format!( "{}: {}\n\ny = approve, n = deny", approval.tool_name, approval.input_summary ); let overlay = Paragraph::new(text).block( Block::bordered() .border_style(Style::default().fg(Color::Yellow)) .title("Tool Approval"), ); frame.render_widget(overlay, overlay_area); } // --- Command overlay (floating box centered on output pane) --- if state.mode == Mode::Command { let overlay_area = command_overlay_rect(output_area); // Clear the area behind the overlay so it appears floating. frame.render_widget(Clear, overlay_area); let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block( Block::bordered() .border_style(Style::default().fg(Color::Yellow)) .title("Command"), ); frame.render_widget(overlay, overlay_area); } // --- Input pane --- let (input_title, input_style) = match state.mode { Mode::Normal | Mode::Command => ( "Input (normal)", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), ), Mode::Insert => ("Input", Style::default()), }; let input_text = format!("> {}", state.input); let input_widget = Paragraph::new(input_text) .style(input_style) .block(Block::bordered().title(input_title)); frame.render_widget(input_widget, chunks[1]); // --- Cursor positioning --- match state.mode { Mode::Insert => { // Cursor at end of input text: border(1) + "> " (2) + input len let cursor_x = chunks[1].x + 1 + 2 + state.input.len() as u16; let cursor_y = chunks[1].y + 1; // inside the border frame.set_cursor_position((cursor_x, cursor_y)); } Mode::Command => { // Cursor in the floating overlay let overlay = command_overlay_rect(output_area); // border(1) + ":" (1) + buf len let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16; let cursor_y = overlay.y + 1; // inside the border frame.set_cursor_position((cursor_x, cursor_y)); } Mode::Normal => {} // no cursor } // --- Status bar --- let (mode_label, mode_style) = match state.mode { Mode::Normal => ( " NORMAL ", Style::default().bg(Color::Blue).fg(Color::White), ), Mode::Insert => ( " INSERT ", Style::default().bg(Color::Green).fg(Color::White), ), Mode::Command => ( " COMMAND ", Style::default().bg(Color::Yellow).fg(Color::Black), ), }; let right_text = if let Some(ref err) = state.status_error { err.clone() } else { "tokens: --".to_string() }; let right_style = if state.status_error.is_some() { Style::default().fg(Color::Red) } else { Style::default() }; let bar_width = chunks[2].width as usize; let left_len = mode_label.len(); let right_len = right_text.len(); let pad = bar_width.saturating_sub(left_len + right_len); let status_line = Line::from(vec![ Span::styled(mode_label, mode_style), Span::raw(" ".repeat(pad)), Span::styled(right_text, right_style), ]); let status_widget = Paragraph::new(status_line); frame.render_widget(status_widget, chunks[2]); } #[cfg(test)] mod tests { use super::*; use ratatui::Terminal; use ratatui::backend::TestBackend; #[test] fn render_smoke_test() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let state = AppState::new(); terminal.draw(|frame| render(frame, &state)).unwrap(); // no panic is the assertion } #[test] fn render_shows_role_prefixes() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.messages.push((Role::User, "hi".to_string())); state.messages.push((Role::Assistant, "hello".to_string())); terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!( all_text.contains("You:"), "expected 'You:' in buffer: {all_text:.100}" ); assert!( all_text.contains("Assistant:"), "expected 'Assistant:' in buffer" ); } // --- update_scroll tests --- #[test] fn update_scroll_zero_when_fits() { let mut state = AppState::new(); state.messages.push((Role::User, "hello".to_string())); let area = Rect::new(0, 0, 80, 24); update_scroll(&mut state, area); assert_eq!(state.scroll, 0); } #[test] fn update_scroll_positive_when_overflow() { let mut state = AppState::new(); for i in 0..50 { state.messages.push((Role::User, format!("message {i}"))); } let area = Rect::new(0, 0, 80, 24); update_scroll(&mut state, area); assert!(state.scroll > 0, "expected scroll > 0 with 50 messages"); } // --- render snapshot tests --- #[test] fn render_status_bar_normal_mode() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.mode = Mode::Normal; terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!(all_text.contains("NORMAL"), "expected 'NORMAL' in buffer"); assert!( all_text.contains("tokens: --"), "expected 'tokens: --' in buffer" ); } #[test] fn render_status_bar_insert_mode() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let state = AppState::new(); // starts in Insert terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!(all_text.contains("INSERT"), "expected 'INSERT' in buffer"); } #[test] fn render_status_bar_command_mode() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.mode = Mode::Command; terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!(all_text.contains("COMMAND"), "expected 'COMMAND' in buffer"); } #[test] fn render_command_overlay_visible_in_command_mode() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.mode = Mode::Command; state.command_buffer = "quit".to_string(); terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!( all_text.contains(":quit"), "expected ':quit' overlay in buffer" ); } #[test] fn render_no_overlay_in_normal_mode() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.mode = Mode::Normal; terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); // Row 1 should not have a ":" prefix from the overlay let row1: String = (0..80) .map(|x| buf.cell((x, 1)).unwrap().symbol().to_string()) .collect(); assert!( !row1.starts_with(':'), "overlay should not appear in Normal mode" ); } #[test] fn render_input_pane_dimmed_in_normal() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.mode = Mode::Normal; terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!( all_text.contains("Input (normal)"), "expected 'Input (normal)' title in Normal mode" ); } #[test] fn render_status_bar_shows_error() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.status_error = Some("Unknown command: foo".to_string()); terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!( all_text.contains("Unknown command: foo"), "expected error in status bar" ); } #[test] fn render_approval_overlay_visible() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).unwrap(); let mut state = AppState::new(); state.pending_approval = Some(super::super::events::PendingApproval { tool_use_id: "t1".to_string(), tool_name: "write_file".to_string(), input_summary: "path: foo.txt".to_string(), }); terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf .content() .iter() .map(|c| c.symbol().to_string()) .collect(); assert!( all_text.contains("Tool Approval"), "expected 'Tool Approval' overlay" ); assert!( all_text.contains("write_file"), "expected tool name in overlay" ); } }