diff options
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 289 |
1 files changed, 277 insertions, 12 deletions
diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs index 4003344..9349183 100644 --- a/makima/src/daemon/tui/ui.rs +++ b/makima/src/daemon/tui/ui.rs @@ -8,8 +8,8 @@ use ratatui::{ Frame, }; -use super::app::{App, InputMode, ViewType}; -use super::event::get_help_text; +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) { @@ -31,20 +31,21 @@ pub fn render(frame: &mut Frame, app: &App) { 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 title and search bar +/// Render header with breadcrumb and search bar fn render_header(frame: &mut Frame, app: &App, area: Rect) { - let title = match app.view_type { - ViewType::Tasks => "Tasks", - ViewType::Contracts => "Contracts", - ViewType::Files => "Files", - }; + let breadcrumb = app.get_breadcrumb(); let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { - format!("{} [Search: {}]", title, app.search_query) + format!("{} [Search: {}]", breadcrumb, app.search_query) } else { - format!("{} ({} items)", title, app.filtered_items.len()) + format!("{} ({} items)", breadcrumb, app.filtered_items.len()) }; let header = Paragraph::new(header_text) @@ -62,6 +63,12 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { /// 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() @@ -141,14 +148,31 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { /// Render footer with help text and status fn render_footer(frame: &mut Frame, app: &App, area: Rect) { - let help_text = get_help_text(app.input_mode); + // 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, status_text); + let footer_text = format!("{}{}{}", help_text, ws_status, status_text); let footer = Paragraph::new(footer_text) .style(Style::default().fg(Color::DarkGray)) @@ -207,3 +231,244 @@ fn render_confirm_dialog(frame: &mut Frame, app: &App) { 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<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()) + } + } +} |
