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.rs289
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())
+ }
+ }
+}