summaryrefslogblamecommitdiff
path: root/makima/src/daemon/tui/ui.rs
blob: 2a5a6ce1c804102c3386f3b7b740b5c98f40076d (plain) (tree)
1
2
3
4
5
6
7
8
9
10









                                                                      
                                                                                                  
                                                        




















                                                    




                                                                                   




                                                                                       

 
                                                
                                                            
                                          

                                                                                              
                                                                
            
                                                                      
















                                                                                      





                                              














































































                                                                 

















                                                                        





                                        
                                                                           

























































                                                                                              



























































































































                                                                                                  

                                                       
                                                                                  
                            




                                                                                        
                                                           






                                                        













                                                                 






                                                                                         

                                                                                      













































                                                                                                                      
                        

















































                                                                                                                                           


























































                                                                                                        







                                                                                                        

            









                                                           




















































































































                                                                                                                           
//! 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())
        }
    }
}