diff options
Diffstat (limited to 'makima/src/daemon/tui/app.rs')
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 536 |
1 files changed, 483 insertions, 53 deletions
diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs index a2c82a2..3eff998 100644 --- a/makima/src/daemon/tui/app.rs +++ b/makima/src/daemon/tui/app.rs @@ -1,28 +1,55 @@ //! TUI application state and logic. +use std::collections::VecDeque; + use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde_json::Value; use uuid::Uuid; +/// Maximum number of output lines to keep in buffer +const MAX_OUTPUT_LINES: usize = 10000; + /// Available views/resource types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ViewType { - Tasks, + /// List of contracts Contracts, - Files, + /// Tasks for a specific contract + Tasks, + /// Task output streaming view + TaskOutput, } impl ViewType { pub fn as_str(&self) -> &'static str { match self { - ViewType::Tasks => "tasks", ViewType::Contracts => "contracts", - ViewType::Files => "files", + ViewType::Tasks => "tasks", + ViewType::TaskOutput => "output", } } } +/// A saved view state for navigation stack +#[derive(Debug, Clone)] +pub struct ViewState { + /// The type of view + pub view_type: ViewType, + /// Contract ID (for Tasks view) + pub contract_id: Option<Uuid>, + /// Contract name (for breadcrumb display) + pub contract_name: Option<String>, + /// Task ID (for TaskOutput view) + pub task_id: Option<Uuid>, + /// Task name (for breadcrumb display) + pub task_name: Option<String>, + /// Selected index at time of navigation + pub selected_index: usize, + /// Scroll offset at time of navigation + pub scroll_offset: usize, +} + /// Input mode for the TUI #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputMode { @@ -32,6 +59,142 @@ pub enum InputMode { Search, /// Confirmation dialog (e.g., for delete) Confirm, + /// Edit mode - editing name + EditName, + /// Edit mode - editing description/plan + EditDescription, +} + +/// Edit state for inline editing +#[derive(Debug, Clone, Default)] +pub struct EditState { + /// ID of the item being edited + pub item_id: Option<Uuid>, + /// Original name + pub original_name: String, + /// Original description + pub original_description: String, + /// Current name value + pub name: String, + /// Current description value + pub description: String, + /// Cursor position in current field + pub cursor: usize, +} + +/// Output line type for rendering +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutputMessageType { + /// Assistant text response + Assistant, + /// Tool being called + ToolUse, + /// Result from tool + ToolResult, + /// Final result/summary + Result, + /// System message + System, + /// Error message + Error, + /// Raw/unformatted output + Raw, +} + +impl OutputMessageType { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "assistant" => Self::Assistant, + "tool_use" => Self::ToolUse, + "tool_result" => Self::ToolResult, + "result" => Self::Result, + "system" => Self::System, + "error" => Self::Error, + _ => Self::Raw, + } + } +} + +/// A single line of task output +#[derive(Debug, Clone)] +pub struct OutputLine { + /// The type of message + pub message_type: OutputMessageType, + /// The content text + pub content: String, + /// Tool name (for tool_use messages) + pub tool_name: Option<String>, + /// Whether this is an error (for tool_result) + pub is_error: bool, + /// Cost in USD (for result messages) + pub cost_usd: Option<f64>, + /// Duration in ms (for result messages) + pub duration_ms: Option<u64>, +} + +/// Output buffer for task output view +#[derive(Debug, Clone, Default)] +pub struct OutputBuffer { + /// Lines of output + pub lines: VecDeque<OutputLine>, + /// Current scroll offset (0 = bottom, auto-scroll) + pub scroll_offset: usize, + /// Auto-scroll enabled + pub auto_scroll: bool, +} + +impl OutputBuffer { + pub fn new() -> Self { + Self { + lines: VecDeque::new(), + scroll_offset: 0, + auto_scroll: true, + } + } + + pub fn add_line(&mut self, line: OutputLine) { + self.lines.push_back(line); + // Trim to max size + while self.lines.len() > MAX_OUTPUT_LINES { + self.lines.pop_front(); + } + // Auto-scroll to bottom + if self.auto_scroll { + self.scroll_offset = 0; + } + } + + pub fn clear(&mut self) { + self.lines.clear(); + self.scroll_offset = 0; + self.auto_scroll = true; + } + + pub fn scroll_up(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_add(amount); + self.auto_scroll = false; + } + + pub fn scroll_down(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + if self.scroll_offset == 0 { + self.auto_scroll = true; + } + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = 0; + self.auto_scroll = true; + } +} + +/// WebSocket connection state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WsConnectionState { + Disconnected, + Connecting, + Connected, + Reconnecting, } /// Actions that can be performed @@ -43,9 +206,13 @@ pub enum Action { Up, /// Move selection down Down, - /// Select current item (show details) + /// Select current item (show details in preview) Select, - /// Edit the selected item (open in editor) + /// Drill down into selected item (contracts -> tasks, tasks -> output) + DrillDown, + /// Go back to previous view + GoBack, + /// Edit the selected item (inline editing) Edit, /// Delete the selected item Delete, @@ -73,6 +240,30 @@ pub enum Action { LaunchEditor(String), /// Refresh data Refresh, + /// Request to load tasks for a contract (internal) + LoadTasks { contract_id: Uuid, contract_name: String }, + /// Request to load task output (internal) + LoadTaskOutput { task_id: Uuid, task_name: String }, + /// Request to delete an item (internal) + PerformDelete { id: Uuid, item_type: ViewType }, + /// Add character in edit mode + EditChar(char), + /// Backspace in edit mode + EditBackspace, + /// Switch to next edit field (Tab) + EditNextField, + /// Save edit changes + EditSave, + /// Cancel edit + EditCancel, + /// Request to perform update (internal) + PerformUpdate { id: Uuid, item_type: ViewType, name: String, description: String }, + /// Scroll output up + ScrollUp, + /// Scroll output down + ScrollDown, + /// Scroll to bottom of output + ScrollToBottom, } /// A displayable item in the TUI @@ -179,6 +370,27 @@ impl ListItem { let mut lines = Vec::new(); match view_type { + ViewType::Contracts => { + lines.push(format!("╭─ Contract Details ─────────────────────")); + lines.push(format!("│ Name: {}", self.name)); + lines.push(format!("│ ID: {}", self.id)); + if let Some(ref status) = self.status { + lines.push(format!("│ Phase: {}", status)); + } + if let Some(ref desc) = self.description { + lines.push(format!("│ Description: {}", desc)); + } + // Show task count if available + if let Some(count) = self.extra.get("taskCount").and_then(|v| v.as_i64()) { + lines.push(format!("│ Tasks: {}", count)); + } + if let Some(count) = self.extra.get("fileCount").and_then(|v| v.as_i64()) { + lines.push(format!("│ Files: {}", count)); + } + lines.push(format!("│")); + lines.push(format!("│ Press Enter to view tasks")); + lines.push(format!("╰────────────────────────────────────────")); + } ViewType::Tasks => { lines.push(format!("╭─ Task Details ─────────────────────────")); lines.push(format!("│ Name: {}", self.name)); @@ -193,44 +405,20 @@ impl ListItem { lines.push(format!("│ Worktree: {}", path)); } // Add progress if available - if let Some(progress) = self.extra.get("progress").and_then(|v| v.as_str()) { + if let Some(progress) = self.extra.get("progressSummary").and_then(|v| v.as_str()) { lines.push(format!("│ Progress: {}", progress)); } - if let Some(error) = self.extra.get("error").and_then(|v| v.as_str()) { + if let Some(error) = self.extra.get("errorMessage").and_then(|v| v.as_str()) { lines.push(format!("│ Error: {}", error)); } + lines.push(format!("│")); + lines.push(format!("│ Press Enter to view output")); lines.push(format!("╰────────────────────────────────────────")); } - ViewType::Contracts => { - lines.push(format!("╭─ Contract Details ─────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref status) = self.status { - lines.push(format!("│ Phase: {}", status)); - } - if let Some(ref desc) = self.description { - lines.push(format!("│ Description: {}", desc)); - } - lines.push(format!("╰────────────────────────────────────────")); - } - ViewType::Files => { - lines.push(format!("╭─ File Details ─────────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref desc) = self.description { - lines.push(format!("│ {}", desc)); - } - // Add content preview if available - if let Some(content) = self.extra.get("content").and_then(|v| v.as_str()) { - lines.push(format!("│")); - lines.push(format!("│ Content:")); - for line in content.lines().take(10) { - lines.push(format!("│ {}", line)); - } - if content.lines().count() > 10 { - lines.push(format!("│ ... ({} more lines)", content.lines().count() - 10)); - } - } + ViewType::TaskOutput => { + // Output view doesn't use this preview pane + lines.push(format!("╭─ Task Output ──────────────────────────")); + lines.push(format!("│ Streaming task output...")); lines.push(format!("╰────────────────────────────────────────")); } } @@ -243,6 +431,16 @@ impl ListItem { pub struct App { /// Current view type pub view_type: ViewType, + /// Navigation stack for drill-down views + pub view_stack: Vec<ViewState>, + /// Current contract ID (when viewing tasks) + pub contract_id: Option<Uuid>, + /// Current contract name (for breadcrumb) + pub contract_name: Option<String>, + /// Current task ID (when viewing output) + pub task_id: Option<Uuid>, + /// Current task name (for breadcrumb) + pub task_name: Option<String>, /// All items (unfiltered) pub items: Vec<ListItem>, /// Filtered items (based on search) @@ -261,20 +459,29 @@ pub struct App { pub preview_visible: bool, /// Pending delete item (for confirmation) pub pending_delete: Option<Uuid>, + /// Edit state for inline editing + pub edit_state: EditState, /// Status message pub status_message: Option<String>, /// Whether the app should quit pub should_quit: bool, /// Action to return when exiting (for OutputPath, LaunchEditor) pub exit_action: Option<Action>, - /// Contract ID (for API calls) - pub contract_id: Option<Uuid>, + /// Output buffer for task output view + pub output_buffer: OutputBuffer, + /// WebSocket connection state + pub ws_state: WsConnectionState, } impl App { pub fn new(view_type: ViewType) -> Self { Self { view_type, + view_stack: Vec::new(), + contract_id: None, + contract_name: None, + task_id: None, + task_name: None, items: Vec::new(), filtered_items: Vec::new(), selected_index: 0, @@ -284,13 +491,85 @@ impl App { preview_content: String::new(), preview_visible: false, pending_delete: None, + edit_state: EditState::default(), status_message: None, should_quit: false, exit_action: None, - contract_id: None, + output_buffer: OutputBuffer::new(), + ws_state: WsConnectionState::Disconnected, } } + /// Push current state to navigation stack and prepare for new view + pub fn push_view(&mut self, new_view: ViewType) { + // Save current state + let state = ViewState { + view_type: self.view_type, + contract_id: self.contract_id, + contract_name: self.contract_name.clone(), + task_id: self.task_id, + task_name: self.task_name.clone(), + selected_index: self.selected_index, + scroll_offset: 0, // TODO: track scroll offset if needed + }; + self.view_stack.push(state); + + // Switch to new view + self.view_type = new_view; + self.items.clear(); + self.filtered_items.clear(); + self.selected_index = 0; + self.search_query.clear(); + self.preview_content.clear(); + self.preview_visible = false; + } + + /// Pop from navigation stack and restore previous view state + pub fn pop_view(&mut self) -> bool { + if let Some(state) = self.view_stack.pop() { + self.view_type = state.view_type; + self.contract_id = state.contract_id; + self.contract_name = state.contract_name; + self.task_id = state.task_id; + self.task_name = state.task_name; + self.selected_index = state.selected_index; + self.items.clear(); + self.filtered_items.clear(); + self.search_query.clear(); + self.preview_content.clear(); + self.preview_visible = false; + true + } else { + false + } + } + + /// Check if we can go back + pub fn can_go_back(&self) -> bool { + !self.view_stack.is_empty() + } + + /// Get breadcrumb path for current view + pub fn get_breadcrumb(&self) -> String { + let mut parts = vec!["Contracts".to_string()]; + + if self.view_type == ViewType::Tasks || self.view_type == ViewType::TaskOutput { + if let Some(ref name) = self.contract_name { + parts.push(name.clone()); + } + parts.push("Tasks".to_string()); + } + + if self.view_type == ViewType::TaskOutput { + if let Some(ref name) = self.task_name { + parts.push(name.clone()); + } + parts.push("Output".to_string()); + } + + parts.join(" > ") + } + /// Set items and update filtered list pub fn set_items(&mut self, items: Vec<ListItem>) { self.items = items; @@ -349,22 +628,130 @@ impl App { } Action::None } + Action::DrillDown => { + // Drill down into selected item + match self.view_type { + ViewType::Contracts => { + // From contracts, drill into tasks + if let Some(item) = self.selected_item() { + let contract_id = item.id; + let contract_name = item.name.clone(); + return Action::LoadTasks { contract_id, contract_name }; + } + } + ViewType::Tasks => { + // From tasks, drill into task output + if let Some(item) = self.selected_item() { + let task_id = item.id; + let task_name = item.name.clone(); + return Action::LoadTaskOutput { task_id, task_name }; + } + } + ViewType::TaskOutput => { + // No further drill-down from output view + } + } + Action::None + } + Action::GoBack => { + if self.can_go_back() { + self.pop_view(); + // Signal to caller to refresh data for the restored view + Action::Refresh + } else { + // At root level, quit + self.should_quit = true; + Action::Quit + } + } Action::Edit => { - // Get worktree path and signal to launch editor + // Enter edit mode for selected item if let Some(item) = self.selected_item() { - if let Some(path) = item.get_worktree_path() { - self.should_quit = true; - self.exit_action = Some(Action::LaunchEditor(path.clone())); - return Action::LaunchEditor(path); - } else { - self.status_message = Some("No worktree path for this item".to_string()); + let name = item.name.clone(); + let description = item.description.clone().unwrap_or_default(); + self.edit_state = EditState { + item_id: Some(item.id), + original_name: name.clone(), + original_description: description.clone(), + name, + description, + cursor: 0, + }; + self.edit_state.cursor = self.edit_state.name.len(); + self.input_mode = InputMode::EditName; + } + Action::None + } + Action::EditChar(c) => { + match self.input_mode { + InputMode::EditName => { + self.edit_state.name.insert(self.edit_state.cursor, c); + self.edit_state.cursor += 1; } + InputMode::EditDescription => { + self.edit_state.description.insert(self.edit_state.cursor, c); + self.edit_state.cursor += 1; + } + _ => {} } Action::None } + Action::EditBackspace => { + match self.input_mode { + InputMode::EditName => { + if self.edit_state.cursor > 0 { + self.edit_state.cursor -= 1; + self.edit_state.name.remove(self.edit_state.cursor); + } + } + InputMode::EditDescription => { + if self.edit_state.cursor > 0 { + self.edit_state.cursor -= 1; + self.edit_state.description.remove(self.edit_state.cursor); + } + } + _ => {} + } + Action::None + } + Action::EditNextField => { + match self.input_mode { + InputMode::EditName => { + self.input_mode = InputMode::EditDescription; + self.edit_state.cursor = self.edit_state.description.len(); + } + InputMode::EditDescription => { + self.input_mode = InputMode::EditName; + self.edit_state.cursor = self.edit_state.name.len(); + } + _ => {} + } + Action::None + } + Action::EditSave => { + if let Some(id) = self.edit_state.item_id { + let name = self.edit_state.name.clone(); + let description = self.edit_state.description.clone(); + self.input_mode = InputMode::Normal; + // Return action to perform the update + return Action::PerformUpdate { + id, + item_type: self.view_type, + name, + description, + }; + } + self.input_mode = InputMode::Normal; + Action::None + } + Action::EditCancel => { + self.edit_state = EditState::default(); + self.input_mode = InputMode::Normal; + self.status_message = Some("Edit cancelled".to_string()); + Action::None + } Action::Delete => { // First press: enter confirm mode - // Clone the values we need to avoid borrow issues if let Some(item) = self.selected_item() { let id = item.id; let name = item.name.clone(); @@ -389,10 +776,13 @@ impl App { } Action::ConfirmYes => { if self.input_mode == InputMode::Confirm { - if let Some(_delete_id) = self.pending_delete.take() { - // TODO: Make API call to delete the item - // For now, just show status - self.status_message = Some("Delete confirmed (API call not implemented)".to_string()); + if let Some(delete_id) = self.pending_delete.take() { + self.input_mode = InputMode::Normal; + // Return action to perform the delete + return Action::PerformDelete { + id: delete_id, + item_type: self.view_type, + }; } self.input_mode = InputMode::Normal; } @@ -447,6 +837,46 @@ impl App { self.exit_action = Some(Action::LaunchEditor(path.clone())); Action::LaunchEditor(path) } + Action::LoadTasks { contract_id, contract_name } => { + // Prepare for tasks view + self.push_view(ViewType::Tasks); + self.contract_id = Some(contract_id); + self.contract_name = Some(contract_name.clone()); + Action::LoadTasks { contract_id, contract_name } + } + Action::LoadTaskOutput { task_id, task_name } => { + // Prepare for output view + self.push_view(ViewType::TaskOutput); + self.task_id = Some(task_id); + self.task_name = Some(task_name.clone()); + Action::LoadTaskOutput { task_id, task_name } + } + Action::PerformDelete { id, item_type } => { + // Pass through to caller for API call + Action::PerformDelete { id, item_type } + } + Action::PerformUpdate { id, item_type, name, description } => { + // Pass through to caller for API call + Action::PerformUpdate { id, item_type, name, description } + } + Action::ScrollUp => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_up(5); + } + Action::None + } + Action::ScrollDown => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_down(5); + } + Action::None + } + Action::ScrollToBottom => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_to_bottom(); + } + Action::None + } Action::None => Action::None, } } |
