//! 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 { /// List of contracts Contracts, /// Tasks for a specific contract Tasks, /// Task output streaming view TaskOutput, } impl ViewType { pub fn as_str(&self) -> &'static str { match self { ViewType::Contracts => "contracts", 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, /// Contract name (for breadcrumb display) pub contract_name: Option, /// Task ID (for TaskOutput view) pub task_id: Option, /// Task name (for breadcrumb display) pub task_name: Option, /// 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 { /// Normal navigation mode Normal, /// Fuzzy search mode 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, /// 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, /// Whether this is an error (for tool_result) pub is_error: bool, /// Cost in USD (for result messages) pub cost_usd: Option, /// Duration in ms (for result messages) pub duration_ms: Option, } /// Output buffer for task output view #[derive(Debug, Clone, Default)] pub struct OutputBuffer { /// Lines of output pub lines: VecDeque, /// 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 #[derive(Debug, Clone, PartialEq)] pub enum Action { /// Do nothing None, /// Move selection up Up, /// Move selection down Down, /// Select current item (show details in preview) Select, /// 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, /// Navigate to worktree (output path and exit) Navigate, /// Confirm pending action ConfirmYes, /// Cancel pending action ConfirmNo, /// Enter search mode EnterSearch, /// Exit search mode ExitSearch, /// Add character to search SearchChar(char), /// Backspace in search SearchBackspace, /// Clear search ClearSearch, /// Quit the application Quit, /// Output a path to stdout and exit (for cd integration) OutputPath(String), /// Launch editor with path 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 #[derive(Debug, Clone)] pub struct ListItem { pub id: Uuid, pub name: String, pub status: Option, pub description: Option, /// Extra data for actions (e.g., worktree path) pub extra: Value, } impl ListItem { pub fn from_task(value: &Value) -> Option { let id = value.get("id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok())?; let name = value.get("name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed") .to_string(); let status = value.get("status") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let description = value.get("plan") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Some(Self { id, name, status, description, extra: value.clone(), }) } pub fn from_contract(value: &Value) -> Option { let id = value.get("id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok())?; let name = value.get("name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed") .to_string(); let status = value.get("phase") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let description = value.get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Some(Self { id, name, status, description, extra: value.clone(), }) } pub fn from_file(value: &Value) -> Option { let id = value.get("id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok())?; let name = value.get("name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed") .to_string(); let description = value.get("template_name") .and_then(|v| v.as_str()) .map(|s| format!("Template: {}", s)); Some(Self { id, name, status: None, description, extra: value.clone(), }) } /// Get the worktree path from task extra data pub fn get_worktree_path(&self) -> Option { // Try various field names that might contain the worktree path self.extra.get("worktreePath") .or_else(|| self.extra.get("worktree_path")) .or_else(|| self.extra.get("workdir")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) } /// Build a detailed view string for display pub fn build_detail_view(&self, view_type: ViewType) -> String { 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)); lines.push(format!("│ ID: {}", self.id)); if let Some(ref status) = self.status { lines.push(format!("│ Status: {}", status)); } if let Some(ref desc) = self.description { lines.push(format!("│ Plan: {}", desc)); } if let Some(path) = self.get_worktree_path() { lines.push(format!("│ Worktree: {}", path)); } // Add progress if available 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("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::TaskOutput => { // Output view doesn't use this preview pane lines.push(format!("╭─ Task Output ──────────────────────────")); lines.push(format!("│ Streaming task output...")); lines.push(format!("╰────────────────────────────────────────")); } } lines.join("\n") } } /// TUI Application state pub struct App { /// Current view type pub view_type: ViewType, /// Navigation stack for drill-down views pub view_stack: Vec, /// Current contract ID (when viewing tasks) pub contract_id: Option, /// Current contract name (for breadcrumb) pub contract_name: Option, /// Current task ID (when viewing output) pub task_id: Option, /// Current task name (for breadcrumb) pub task_name: Option, /// All items (unfiltered) pub items: Vec, /// Filtered items (based on search) pub filtered_items: Vec, /// Currently selected index in filtered list pub selected_index: usize, /// Current input mode pub input_mode: InputMode, /// Search query pub search_query: String, /// Fuzzy matcher matcher: SkimMatcherV2, /// Preview content (for selected item) pub preview_content: String, /// Whether preview is visible pub preview_visible: bool, /// Pending delete item (for confirmation) pub pending_delete: Option, /// Edit state for inline editing pub edit_state: EditState, /// Status message pub status_message: Option, /// Whether the app should quit pub should_quit: bool, /// Action to return when exiting (for OutputPath, LaunchEditor) pub exit_action: Option, /// 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, input_mode: InputMode::Normal, search_query: String::new(), matcher: SkimMatcherV2::default(), preview_content: String::new(), preview_visible: false, pending_delete: None, edit_state: EditState::default(), status_message: None, should_quit: false, exit_action: 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) { self.items = items; self.update_filtered_items(); } /// Update filtered items based on search query pub fn update_filtered_items(&mut self) { if self.search_query.is_empty() { self.filtered_items = self.items.clone(); } else { let mut scored: Vec<_> = self.items .iter() .filter_map(|item| { let score = self.matcher.fuzzy_match(&item.name, &self.search_query)?; Some((score, item.clone())) }) .collect(); // Sort by score (highest first) scored.sort_by(|a, b| b.0.cmp(&a.0)); self.filtered_items = scored.into_iter().map(|(_, item)| item).collect(); } // Reset selection if out of bounds if self.selected_index >= self.filtered_items.len() { self.selected_index = self.filtered_items.len().saturating_sub(1); } } /// Get currently selected item pub fn selected_item(&self) -> Option<&ListItem> { self.filtered_items.get(self.selected_index) } /// Handle an action and return the resulting action pub fn handle_action(&mut self, action: Action) -> Action { match action { Action::Up => { if self.selected_index > 0 { self.selected_index -= 1; } Action::None } Action::Down => { if self.selected_index < self.filtered_items.len().saturating_sub(1) { self.selected_index += 1; } Action::None } Action::Select => { // Build detailed view for selected item if let Some(item) = self.selected_item() { self.preview_content = item.build_detail_view(self.view_type); self.preview_visible = true; } 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 => { // Enter edit mode for selected item if let Some(item) = self.selected_item() { 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 if let Some(item) = self.selected_item() { let id = item.id; let name = item.name.clone(); self.pending_delete = Some(id); self.input_mode = InputMode::Confirm; self.status_message = Some(format!("Delete '{}'? (y/n)", name)); } Action::None } Action::Navigate => { // Get worktree path and output it if let Some(item) = self.selected_item() { if let Some(path) = item.get_worktree_path() { self.should_quit = true; self.exit_action = Some(Action::OutputPath(path.clone())); return Action::OutputPath(path); } else { self.status_message = Some("No worktree path for this item".to_string()); } } Action::None } Action::ConfirmYes => { if self.input_mode == InputMode::Confirm { 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; } Action::None } Action::ConfirmNo => { if self.input_mode == InputMode::Confirm { self.pending_delete = None; self.input_mode = InputMode::Normal; self.status_message = Some("Delete cancelled".to_string()); } Action::None } Action::EnterSearch => { self.input_mode = InputMode::Search; Action::None } Action::ExitSearch => { self.input_mode = InputMode::Normal; Action::None } Action::SearchChar(c) => { self.search_query.push(c); self.update_filtered_items(); Action::None } Action::SearchBackspace => { self.search_query.pop(); self.update_filtered_items(); Action::None } Action::ClearSearch => { self.search_query.clear(); self.update_filtered_items(); Action::None } Action::Quit => { self.should_quit = true; Action::Quit } Action::Refresh => { // Signal to caller to refresh data Action::Refresh } Action::OutputPath(path) => { self.should_quit = true; self.exit_action = Some(Action::OutputPath(path.clone())); Action::OutputPath(path) } Action::LaunchEditor(path) => { self.should_quit = true; 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, } } /// Get the name of the item being deleted (for confirmation dialog) pub fn get_pending_delete_name(&self) -> Option { self.pending_delete.and_then(|id| { self.filtered_items.iter() .find(|item| item.id == id) .map(|item| item.name.clone()) }) } }