//! 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, CreateFormField, 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 create contract dialog if in create mode if matches!(app.input_mode, InputMode::CreateName | InputMode::CreateDescription) { render_create_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 create contract dialog fn render_create_dialog(frame: &mut Frame, app: &App) { // Calculate popup size and position - make it taller if suggestions are shown let area = frame.area(); let state = &app.create_state; let current_field = state.current_field(); // Show suggestions whenever we have them (like the frontend does) let show_suggestions = state.show_suggestions && !state.repo_suggestions.is_empty(); let popup_width = 70.min(area.width.saturating_sub(4)); let base_height = 20; let suggestion_height = if show_suggestions { (state.repo_suggestions.len().min(5) + 2) as u16 } else { 0 }; let popup_height = base_height + suggestion_height; 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); let max_field_width = (popup_width as usize).saturating_sub(18); // Styles 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); let hint_style = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC); let suggestion_style = Style::default().fg(Color::White); let selected_suggestion_style = Style::default().fg(Color::Black).bg(Color::Cyan); // Helper to build text field with cursor let build_field = |value: &str, cursor: usize, is_active: bool| -> String { if is_active { let cursor_pos = cursor.min(value.len()); let (before, after) = value.split_at(cursor_pos); let display = format!("{}|{}", before, after); if display.len() > max_field_width { let start = display.len().saturating_sub(max_field_width); format!("...{}", &display[start..]) } else { display } } else { if value.len() > max_field_width { format!("{}...", &value[..max_field_width.saturating_sub(3)]) } else if value.is_empty() { "(empty)".to_string() } else { value.to_string() } } }; // Build field displays let name_display = build_field(&state.name, state.cursor, current_field == CreateFormField::Name); let desc_display = build_field(&state.description, state.cursor, current_field == CreateFormField::Description); let repo_display = build_field(&state.repository_url, state.cursor, current_field == CreateFormField::Repository); // Contract type selector let type_display = if state.contract_type == "simple" { vec![ Span::styled("[●] ", Style::default().fg(Color::Green)), Span::raw("Simple "), Span::styled("[ ] ", Style::default().fg(Color::DarkGray)), Span::styled("Specification", Style::default().fg(Color::DarkGray)), ] } else { vec![ Span::styled("[ ] ", Style::default().fg(Color::DarkGray)), Span::styled("Simple ", Style::default().fg(Color::DarkGray)), Span::styled("[●] ", Style::default().fg(Color::Green)), Span::raw("Specification"), ] }; let mut text = vec![ Line::from(""), Line::from(Span::styled( " New Contract", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), )), Line::from(""), // Name field (required) Line::from(vec![ Span::styled(" Name*: ", label_style), Span::styled( name_display, if current_field == CreateFormField::Name { active_style } else { inactive_style }, ), ]), Line::from(Span::styled(" Contract name (required)", hint_style)), Line::from(""), // Description field Line::from(vec![ Span::styled(" Description: ", label_style), Span::styled( desc_display, if current_field == CreateFormField::Description { active_style } else { inactive_style }, ), ]), Line::from(Span::styled(" Brief description of the work", hint_style)), Line::from(""), // Contract type selector Line::from(vec![ Span::styled(" Type: ", label_style), ].into_iter().chain( if current_field == CreateFormField::ContractType { type_display.into_iter().map(|s| s).collect::>() } else { type_display.into_iter().map(|mut s| { s.style = s.style.fg(Color::DarkGray); s }).collect() } ).collect::>()), Line::from(Span::styled(" Simple: Plan→Execute | Spec: Research→Specify→Plan→Execute→Review", hint_style)), Line::from(""), // Repository URL field Line::from(vec![ Span::styled(" Repository: ", label_style), Span::styled( repo_display, if current_field == CreateFormField::Repository { active_style } else { inactive_style }, ), ]), Line::from(Span::styled(" Git repository URL (optional)", hint_style)), ]; // Add suggestions section if show_suggestions { text.push(Line::from("")); text.push(Line::from(Span::styled( " Recent repositories (↑/↓ to select, Enter to apply):", Style::default().fg(Color::Cyan), ))); for (i, suggestion) in state.repo_suggestions.iter().take(5).enumerate() { let is_selected = i == state.selected_suggestion; let url_or_path = suggestion.repository_url.as_ref() .or(suggestion.local_path.as_ref()) .map(|s| s.as_str()) .unwrap_or(""); // Truncate if too long let display_url = if url_or_path.len() > max_field_width - 10 { format!("...{}", &url_or_path[url_or_path.len().saturating_sub(max_field_width - 13)..]) } else { url_or_path.to_string() }; let prefix = if is_selected { " → " } else { " " }; let count_suffix = format!(" ({}×)", suggestion.use_count); text.push(Line::from(vec![ Span::styled( format!("{}{}{}", prefix, display_url, count_suffix), if is_selected { selected_suggestion_style } else { suggestion_style }, ), ])); } } else if state.suggestions_loaded && state.repo_suggestions.is_empty() { // Show message when suggestions loaded but empty text.push(Line::from("")); text.push(Line::from(Span::styled( " (No recent repositories - add repos to contracts to see suggestions here)", hint_style, ))); } text.push(Line::from("")); // Help line - show different help when suggestions are visible if show_suggestions { text.push(Line::from(vec![ Span::styled(" ↑/↓", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": select "), Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": apply "), Span::styled("Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": next field "), Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw(": cancel"), ])); } else { text.push(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(": create "), Span::styled("Space", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), Span::raw(": toggle type "), 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(" Create Contract ")); 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()) } } }