//! TUI rendering. use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; use super::app::{App, InputMode, ViewType, OutputMessageType, WsConnectionState}; use super::event::{get_help_text, get_output_help_text}; /// Main render function pub fn render(frame: &mut Frame, app: &App) { // Create main layout let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(10), // Main content Constraint::Length(3), // Status/Help ]) .split(frame.area()); render_header(frame, app, chunks[0]); render_main_content(frame, app, chunks[1]); render_footer(frame, app, chunks[2]); // Render confirmation dialog if in confirm mode if app.input_mode == InputMode::Confirm { render_confirm_dialog(frame, app); } // Render edit dialog if in edit mode if matches!(app.input_mode, InputMode::EditName | InputMode::EditDescription) { render_edit_dialog(frame, app); } } /// Render header with breadcrumb and search bar fn render_header(frame: &mut Frame, app: &App, area: Rect) { let breadcrumb = app.get_breadcrumb(); let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { format!("{} [Search: {}]", breadcrumb, app.search_query) } else { format!("{} ({} items)", breadcrumb, app.filtered_items.len()) }; let header = Paragraph::new(header_text) .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if app.input_mode == InputMode::Search { Color::Yellow } else { Color::White }))); frame.render_widget(header, area); } /// Render main content (list + optional preview) fn render_main_content(frame: &mut Frame, app: &App, area: Rect) { // TaskOutput view has its own rendering if app.view_type == ViewType::TaskOutput { render_output_view(frame, app, area); return; } if app.preview_visible && !app.preview_content.is_empty() { // Split horizontally: list on left, preview on right let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(50), Constraint::Percentage(50), ]) .split(area); render_list(frame, app, chunks[0]); render_preview(frame, app, chunks[1]); } else { render_list(frame, app, area); } } /// Render the item list fn render_list(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = app.filtered_items .iter() .enumerate() .map(|(i, item)| { let is_selected = i == app.selected_index; // Build the display line let status_str = item.status .as_ref() .map(|s| format!(" [{}]", s)) .unwrap_or_default(); let content = format!("{}{}", item.name, status_str); let style = if is_selected { Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { let status_color = item.status.as_ref().map(|s| { match s.to_lowercase().as_str() { "running" | "active" => Color::Green, "pending" | "waiting" => Color::Yellow, "completed" | "done" => Color::Blue, "failed" | "error" => Color::Red, _ => Color::White, } }).unwrap_or(Color::White); Style::default().fg(status_color) }; ListItem::new(Line::from(vec![ Span::styled(content, style), ])) }) .collect(); let list = List::new(items) .block(Block::default() .borders(Borders::ALL) .title(format!(" {} ", app.view_type.as_str()))); frame.render_widget(list, area); } /// Render the preview panel fn render_preview(frame: &mut Frame, app: &App, area: Rect) { let preview = Paragraph::new(Text::raw(&app.preview_content)) .wrap(Wrap { trim: false }) .block(Block::default() .borders(Borders::ALL) .title(" Preview ")); frame.render_widget(preview, area); } /// Render footer with help text and status fn render_footer(frame: &mut Frame, app: &App, area: Rect) { // Use output-specific help text when in output view let help_text = if app.view_type == ViewType::TaskOutput { get_output_help_text() } else { get_help_text(app.input_mode) }; // Build status text with WS connection state for output view let ws_status = if app.view_type == ViewType::TaskOutput { match app.ws_state { WsConnectionState::Connected => " [WS: Connected]", WsConnectionState::Connecting => " [WS: Connecting...]", WsConnectionState::Reconnecting => " [WS: Reconnecting...]", WsConnectionState::Disconnected => " [WS: Disconnected]", } } else { "" }; let status_text = app.status_message .as_ref() .map(|s| format!(" | {}", s)) .unwrap_or_default(); let footer_text = format!("{}{}{}", help_text, ws_status, status_text); let footer = Paragraph::new(footer_text) .style(Style::default().fg(Color::DarkGray)) .block(Block::default().borders(Borders::ALL)); frame.render_widget(footer, area); } /// Render confirmation dialog as a centered popup fn render_confirm_dialog(frame: &mut Frame, app: &App) { let item_name = app.get_pending_delete_name() .unwrap_or_else(|| "this item".to_string()); // Calculate popup size and position let area = frame.area(); let popup_width = 50.min(area.width.saturating_sub(4)); let popup_height = 7; let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect { x: popup_x, y: popup_y, width: popup_width, height: popup_height, }; // Clear the area behind the popup frame.render_widget(Clear, popup_area); // Build popup content let text = vec![ Line::from(""), Line::from(Span::styled( "Delete Confirmation", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(format!("Delete '{}'?", item_name)), Line::from(""), Line::from(vec![ Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": confirm "), Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw(": cancel"), ]), ]; let popup = Paragraph::new(text) .alignment(Alignment::Center) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Red)) .title(" Confirm ")); frame.render_widget(popup, popup_area); } /// Render edit dialog as a centered popup fn render_edit_dialog(frame: &mut Frame, app: &App) { // Calculate popup size and position - make it wider let area = frame.area(); let popup_width = 80.min(area.width.saturating_sub(4)); let popup_height = 14; let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect { x: popup_x, y: popup_y, width: popup_width, height: popup_height, }; // Clear the area behind the popup frame.render_widget(Clear, popup_area); // Determine which field is active let editing_name = app.input_mode == InputMode::EditName; // Calculate max display width (popup width - borders - label) let max_field_width = (popup_width as usize).saturating_sub(16); // Build the name field with cursor and truncation let name_display = if editing_name { let cursor_pos = app.edit_state.cursor.min(app.edit_state.name.len()); let (before, after) = app.edit_state.name.split_at(cursor_pos); let display = format!("{}|{}", before, after); // Show end of string if cursor is past visible area if display.len() > max_field_width { let start = display.len().saturating_sub(max_field_width); format!("...{}", &display[start..]) } else { display } } else { if app.edit_state.name.len() > max_field_width { format!("{}...", &app.edit_state.name[..max_field_width.saturating_sub(3)]) } else { app.edit_state.name.clone() } }; // Build the description field with cursor and truncation let desc_display = if !editing_name { let cursor_pos = app.edit_state.cursor.min(app.edit_state.description.len()); let (before, after) = app.edit_state.description.split_at(cursor_pos); let display = format!("{}|{}", before, after); // Show end of string if cursor is past visible area if display.len() > max_field_width { let start = display.len().saturating_sub(max_field_width); format!("...{}", &display[start..]) } else { display } } else { if app.edit_state.description.len() > max_field_width { format!("{}...", &app.edit_state.description[..max_field_width.saturating_sub(3)]) } else { app.edit_state.description.clone() } }; // Determine field label based on view type let desc_label = match app.view_type { ViewType::Tasks => "Plan", _ => "Desc", }; // Style for active vs inactive fields let active_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let inactive_style = Style::default().fg(Color::White); let label_style = Style::default().fg(Color::DarkGray); // Build popup content - use left alignment for fields let text = vec![ Line::from(""), Line::from(Span::styled( " Edit Item", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), )), Line::from(""), // Name field Line::from(vec![ Span::styled(" Name: ", label_style), Span::styled( name_display, if editing_name { active_style } else { inactive_style }, ), ]), Line::from(""), // Description field Line::from(vec![ Span::styled(format!(" {}: ", desc_label), label_style), Span::styled( desc_display, if !editing_name { active_style } else { inactive_style }, ), ]), Line::from(""), Line::from(""), Line::from(vec![ Span::styled(" Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": switch "), Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": save "), Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw(": cancel"), ]), ]; let popup = Paragraph::new(text) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)) .title(" Edit ")); frame.render_widget(popup, popup_area); } /// Render the task output view fn render_output_view(frame: &mut Frame, app: &App, area: Rect) { let buffer = &app.output_buffer; // Calculate visible area (subtract 2 for borders) let visible_height = area.height.saturating_sub(2) as usize; // Build lines to display let total_lines = buffer.lines.len(); let start_idx = if total_lines > visible_height { total_lines .saturating_sub(visible_height) .saturating_sub(buffer.scroll_offset) } else { 0 }; let lines: Vec = buffer.lines .iter() .skip(start_idx) .take(visible_height) .map(|line| render_output_line(line)) .collect(); // Build title with scroll indicator let scroll_indicator = if buffer.auto_scroll { "[auto-scroll]".to_string() } else if buffer.scroll_offset > 0 { format!("[+{}]", buffer.scroll_offset) } else { String::new() }; let title = format!(" Task Output {} ", scroll_indicator); let paragraph = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) .title(title) .border_style(Style::default().fg(match app.ws_state { WsConnectionState::Connected => Color::Green, WsConnectionState::Connecting | WsConnectionState::Reconnecting => Color::Yellow, WsConnectionState::Disconnected => Color::Red, }))); frame.render_widget(paragraph, area); } /// Render a single output line with appropriate styling fn render_output_line(line: &super::app::OutputLine) -> Line<'static> { match line.message_type { OutputMessageType::Assistant => { // Blue left indicator for assistant messages Line::from(vec![ Span::styled("│ ", Style::default().fg(Color::Blue)), Span::styled(line.content.clone(), Style::default().fg(Color::White)), ]) } OutputMessageType::ToolUse => { // Yellow asterisk for tool calls let tool_name = line.tool_name.clone().unwrap_or_else(|| "tool".to_string()); Line::from(vec![ Span::styled("* ", Style::default().fg(Color::Yellow)), Span::styled(format!("[{}] ", tool_name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), ]) } OutputMessageType::ToolResult => { // Green/red indicator for tool results let indicator = if line.is_error { "✗ " } else { " + " }; let color = if line.is_error { Color::Red } else { Color::Green }; Line::from(vec![ Span::styled(indicator, Style::default().fg(color)), Span::styled(line.content.clone(), Style::default().fg(Color::Gray)), ]) } OutputMessageType::Result => { // Green checkmark for final results let mut spans = vec![ Span::styled("✓ ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::styled(line.content.clone(), Style::default().fg(Color::Green)), ]; // Add cost/duration if available if let Some(cost) = line.cost_usd { spans.push(Span::styled( format!(" [${:.4}]", cost), Style::default().fg(Color::DarkGray), )); } if let Some(ms) = line.duration_ms { spans.push(Span::styled( format!(" [{}ms]", ms), Style::default().fg(Color::DarkGray), )); } Line::from(spans) } OutputMessageType::System => { // Dim gray for system messages Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), ]) } OutputMessageType::Error => { // Red for errors Line::from(vec![ Span::styled("! ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::styled(line.content.clone(), Style::default().fg(Color::Red)), ]) } OutputMessageType::Raw => { // Plain text Line::from(line.content.clone()) } } }