//! 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<ListItem> = 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::<Vec<_>>()
} else {
type_display.into_iter().map(|mut s| {
s.style = s.style.fg(Color::DarkGray);
s
}).collect()
}
).collect::<Vec<_>>()),
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<Line> = 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())
}
}
}