//! TUI application state and logic. use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde_json::Value; use uuid::Uuid; /// Available views/resource types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ViewType { Tasks, Contracts, Files, } impl ViewType { pub fn as_str(&self) -> &'static str { match self { ViewType::Tasks => "tasks", ViewType::Contracts => "contracts", ViewType::Files => "files", } } } /// 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, } /// 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) Select, /// Edit the selected item (open in editor) 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, } /// 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::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("progress").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()) { lines.push(format!("│ Error: {}", error)); } 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)); } } lines.push(format!("╰────────────────────────────────────────")); } } lines.join("\n") } } /// TUI Application state pub struct App { /// Current view type pub view_type: ViewType, /// 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, /// 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, /// Contract ID (for API calls) pub contract_id: Option, } impl App { pub fn new(view_type: ViewType) -> Self { Self { view_type, 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, status_message: None, should_quit: false, exit_action: None, contract_id: None, } } /// 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::Edit => { // Get worktree path and signal to launch editor 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()); } } 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(); 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() { // 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()); } 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::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()) }) } }