diff options
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 695 |
1 files changed, 0 insertions, 695 deletions
diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs deleted file mode 100644 index 2a5a6ce..0000000 --- a/makima/src/daemon/tui/ui.rs +++ /dev/null @@ -1,695 +0,0 @@ -//! 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()) - } - } -} |
