summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/ui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/ui.rs')
-rw-r--r--makima/src/daemon/tui/ui.rs695
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())
- }
- }
-}