diff options
Diffstat (limited to 'makima/src/daemon')
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 5 | ||||
| -rw-r--r-- | makima/src/daemon/cli/view.rs | 93 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 11 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 1219 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 269 | ||||
| -rw-r--r-- | makima/src/daemon/tui/fuzzy.rs | 217 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 98 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 695 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/contracts.rs | 32 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/files.rs | 90 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/tasks.rs | 71 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/list_view.rs | 127 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/preview_pane.rs | 21 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/search_input.rs | 82 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/status_bar.rs | 19 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ws_client.rs | 353 |
18 files changed, 6 insertions, 3403 deletions
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index acad9ad..077a37e 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -4,7 +4,6 @@ pub mod config; pub mod daemon; pub mod directive; pub mod server; -pub mod view; use clap::{Parser, Subcommand}; @@ -12,7 +11,6 @@ pub use config::CliConfig; pub use daemon::DaemonArgs; pub use directive::DirectiveArgs; pub use server::ServerArgs; -pub use view::ViewArgs; /// Makima - unified CLI for server, daemon, and task management. #[derive(Parser, Debug)] @@ -35,9 +33,6 @@ pub enum Commands { #[command(subcommand)] Directive(DirectiveCommand), - /// Interactive TUI browser for directives and tasks - View(ViewArgs), - /// Configure CLI settings (API key, server URL) /// /// Saves configuration to ~/.makima/config.toml for use by CLI commands. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs deleted file mode 100644 index b9fa82f..0000000 --- a/makima/src/daemon/cli/view.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! View subcommand - interactive TUI browser for contracts and tasks. -//! -//! The `makima view` command provides an interactive Terminal User Interface (TUI) -//! for browsing and managing makima contracts and their tasks. It features -//! drill-down navigation, fuzzy search filtering, and real-time task output streaming. -//! -//! # Usage -//! -//! ```bash -//! # Browse contracts interactively -//! makima view -//! -//! # Browse with an initial search query -//! makima view "my project" -//! -//! # Change directory to selected task's worktree -//! cd $(makima view) -//! ``` -//! -//! # Keyboard Shortcuts -//! -//! | Key | Action | -//! |---------------|-------------------------------| -//! | `↑` / `k` | Move selection up | -//! | `↓` / `j` | Move selection down | -//! | `Enter` / `l` | Drill into item | -//! | `Esc` / `h` | Go back to previous view | -//! | `e` | Edit item (inline) | -//! | `d` | Delete item (with confirm) | -//! | `/` | Focus search input | -//! | `Space` | Show details in preview pane | -//! | `q` | Quit | -//! | `c` | Navigate to worktree (cd) | -//! | `r` | Refresh data | -//! -//! # Navigation -//! -//! - **Contracts view**: Lists all contracts. Press Enter to see tasks. -//! - **Tasks view**: Shows tasks for a contract. Press Enter to view output. -//! - **Output view**: Streams real-time task output with tool call formatting. -//! -//! # Features -//! -//! - **Drill-down Navigation**: Contracts → Tasks → Task Output -//! - **Fuzzy Search**: Type to filter items in real-time -//! - **Real-time Streaming**: View live task output via WebSocket -//! - **Preview Pane**: See item details without leaving the list - -use clap::Args; - -/// Interactive TUI browser for contracts and tasks. -/// -/// Provides a fuzzy-searchable interface for browsing contracts, -/// viewing their tasks, and streaming real-time task output. -/// -/// # Examples -/// -/// Browse contracts: -/// ```bash -/// makima view -/// ``` -/// -/// Browse with initial search: -/// ```bash -/// makima view "auth" -/// ``` -#[derive(Args, Debug, Clone)] -pub struct ViewArgs { - /// API URL for the makima server - /// - /// If not provided, uses MAKIMA_API_URL env var or ~/.makima/config.toml - #[arg(long, env = "MAKIMA_API_URL")] - pub api_url: Option<String>, - - /// API key for authentication - /// - /// If not provided, uses MAKIMA_API_KEY env var or ~/.makima/config.toml - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: Option<String>, - - /// Initial search query - /// - /// Pre-populates the search field with this query when the TUI opens. - #[arg(index = 1)] - pub query: Option<String>, - - /// Disable the preview pane - /// - /// Shows only the item list without the side preview panel. - /// Useful for smaller terminal windows. - #[arg(long)] - pub no_preview: bool, -} diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index e15608b..014b6d7 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -3,9 +3,11 @@ //! This crate provides: //! - `makima server` - Run the makima server //! - `makima daemon` - Run the daemon (connect to server, manage tasks) -//! - `makima supervisor` - Contract orchestration commands -//! - `makima contract` - Task-contract interaction commands -//! - `makima view` - Interactive TUI browser for tasks, contracts, and files +//! - `makima directive` - Directive command group (ask, create-order, etc.) +//! +//! The legacy `makima supervisor` / `makima contract` / `makima view` +//! command groups were removed alongside the legacy contracts + +//! supervisor task-grouping system. pub mod api; pub mod cli; @@ -19,10 +21,9 @@ pub mod skills; pub mod storage; pub mod task; pub mod temp; -pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ViewArgs}; +pub use cli::{Cli, Commands}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs deleted file mode 100644 index cb0e8f3..0000000 --- a/makima/src/daemon/tui/app.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! 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<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 { - /// 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, - /// Create contract - editing name - CreateName, - /// Create contract - editing description - CreateDescription, -} - -/// Create contract form field -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CreateFormField { - Name, - Description, - ContractType, - Repository, -} - -/// Repository suggestion from history -#[derive(Debug, Clone)] -pub struct RepositorySuggestion { - pub name: String, - pub repository_url: Option<String>, - pub local_path: Option<String>, - pub source_type: String, - pub use_count: i32, -} - -/// State for create contract form -#[derive(Debug, Clone, Default)] -pub struct CreateContractState { - /// Contract name - pub name: String, - /// Contract description - pub description: String, - /// Contract type: "simple" or "specification" - pub contract_type: String, - /// Repository URL (optional) - pub repository_url: String, - /// Currently focused field - pub focused_field: usize, - /// Cursor position in current text field - pub cursor: usize, - /// Available repository suggestions - pub repo_suggestions: Vec<RepositorySuggestion>, - /// Selected suggestion index (for repository field) - pub selected_suggestion: usize, - /// Whether suggestions popup is visible - pub show_suggestions: bool, - /// Whether suggestions have been loaded - pub suggestions_loaded: bool, -} - -impl CreateContractState { - pub fn new() -> Self { - Self { - name: String::new(), - description: String::new(), - contract_type: "simple".to_string(), - repository_url: String::new(), - focused_field: 0, - cursor: 0, - repo_suggestions: Vec::new(), - selected_suggestion: 0, - show_suggestions: false, - suggestions_loaded: false, - } - } - - /// Set repository suggestions - pub fn set_suggestions(&mut self, suggestions: Vec<RepositorySuggestion>) { - self.repo_suggestions = suggestions; - self.selected_suggestion = 0; - self.show_suggestions = !self.repo_suggestions.is_empty(); - self.suggestions_loaded = true; - } - - /// Apply the selected suggestion to the form - pub fn apply_selected_suggestion(&mut self) { - if let Some(suggestion) = self.repo_suggestions.get(self.selected_suggestion) { - // Apply the suggestion - if let Some(ref url) = suggestion.repository_url { - self.repository_url = url.clone(); - } else if let Some(ref path) = suggestion.local_path { - self.repository_url = path.clone(); - } - self.cursor = self.repository_url.len(); - self.show_suggestions = false; - } - } - - /// Navigate to next suggestion - pub fn next_suggestion(&mut self) { - if !self.repo_suggestions.is_empty() { - self.selected_suggestion = (self.selected_suggestion + 1) % self.repo_suggestions.len(); - } - } - - /// Navigate to previous suggestion - pub fn prev_suggestion(&mut self) { - if !self.repo_suggestions.is_empty() { - self.selected_suggestion = if self.selected_suggestion == 0 { - self.repo_suggestions.len() - 1 - } else { - self.selected_suggestion - 1 - }; - } - } - - /// Get the field at the given index - pub fn field_at(&self, index: usize) -> CreateFormField { - match index { - 0 => CreateFormField::Name, - 1 => CreateFormField::Description, - 2 => CreateFormField::ContractType, - 3 => CreateFormField::Repository, - _ => CreateFormField::Name, - } - } - - /// Get the current field - pub fn current_field(&self) -> CreateFormField { - self.field_at(self.focused_field) - } - - /// Get mutable reference to the current text field value - pub fn current_value_mut(&mut self) -> Option<&mut String> { - match self.current_field() { - CreateFormField::Name => Some(&mut self.name), - CreateFormField::Description => Some(&mut self.description), - CreateFormField::Repository => Some(&mut self.repository_url), - CreateFormField::ContractType => None, // Not a text field - } - } - - /// Get the current text field value - pub fn current_value(&self) -> Option<&str> { - match self.current_field() { - CreateFormField::Name => Some(&self.name), - CreateFormField::Description => Some(&self.description), - CreateFormField::Repository => Some(&self.repository_url), - CreateFormField::ContractType => None, - } - } - - /// Move to next field - pub fn next_field(&mut self) { - self.focused_field = (self.focused_field + 1) % 4; - self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0); - // Don't hide suggestions - they stay visible - } - - /// Move to previous field - pub fn prev_field(&mut self) { - self.focused_field = if self.focused_field == 0 { 3 } else { self.focused_field - 1 }; - self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0); - // Don't hide suggestions - they stay visible - } - - /// Toggle contract type - pub fn toggle_contract_type(&mut self) { - self.contract_type = if self.contract_type == "simple" { - "specification".to_string() - } else { - "simple".to_string() - }; - } - - /// Insert character at cursor - pub fn insert_char(&mut self, c: char) { - let cursor = self.cursor; - match self.current_field() { - CreateFormField::Name => { - self.name.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::Description => { - self.description.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::Repository => { - self.repository_url.insert(cursor, c); - self.cursor += 1; - } - CreateFormField::ContractType => {} - } - } - - /// Delete character before cursor - pub fn backspace(&mut self) { - if self.cursor > 0 { - let cursor = self.cursor - 1; - match self.current_field() { - CreateFormField::Name => { - self.name.remove(cursor); - self.cursor = cursor; - } - CreateFormField::Description => { - self.description.remove(cursor); - self.cursor = cursor; - } - CreateFormField::Repository => { - self.repository_url.remove(cursor); - self.cursor = cursor; - } - CreateFormField::ContractType => {} - } - } - } - - /// Check if form is valid (name is required) - pub fn is_valid(&self) -> bool { - !self.name.trim().is_empty() - } -} - -/// 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 -#[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, - /// Open create contract form - NewContract, - /// Add character in create form - CreateChar(char), - /// Backspace in create form - CreateBackspace, - /// Move to next field in create form - CreateNextField, - /// Move to previous field in create form - CreatePrevField, - /// Toggle value (for contract type) - CreateToggle, - /// Submit create form - CreateSubmit, - /// Cancel create form - CreateCancel, - /// Request to create contract (internal) - PerformCreateContract { - name: String, - description: String, - contract_type: String, - repository_url: Option<String>, - }, - /// Request to load repository suggestions (internal) - LoadRepoSuggestions, - /// Navigate to next suggestion in create form - CreateNextSuggestion, - /// Navigate to previous suggestion in create form - CreatePrevSuggestion, - /// Apply selected suggestion in create form - CreateApplySuggestion, -} - -/// A displayable item in the TUI -#[derive(Debug, Clone)] -pub struct ListItem { - pub id: Uuid, - pub name: String, - pub status: Option<String>, - pub description: Option<String>, - /// Extra data for actions (e.g., worktree path) - pub extra: Value, -} - -impl ListItem { - pub fn from_task(value: &Value) -> Option<Self> { - 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<Self> { - 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<Self> { - 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<String> { - // 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<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) - pub filtered_items: Vec<ListItem>, - /// 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<Uuid>, - /// Edit state for inline editing - pub edit_state: EditState, - /// Create contract form state - pub create_state: CreateContractState, - /// 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>, - /// 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(), - create_state: CreateContractState::new(), - 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<ListItem>) { - 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(); - // Push view and set state before returning - self.push_view(ViewType::Tasks); - self.contract_id = Some(contract_id); - self.contract_name = Some(contract_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(); - // Push view and set state before returning - self.push_view(ViewType::TaskOutput); - self.task_id = Some(task_id); - self.task_name = Some(task_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 } => { - // Pass through to caller for data loading (view already pushed by DrillDown) - Action::LoadTasks { contract_id, contract_name } - } - Action::LoadTaskOutput { task_id, task_name } => { - // Pass through to caller for data loading (view already pushed by DrillDown) - 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::NewContract => { - // Only allow creating contracts from contracts view - if self.view_type == ViewType::Contracts { - self.create_state = CreateContractState::new(); - self.input_mode = InputMode::CreateName; - // Request to load repository suggestions - return Action::LoadRepoSuggestions; - } - Action::None - } - Action::CreateChar(c) => { - self.create_state.insert_char(c); - Action::None - } - Action::CreateBackspace => { - self.create_state.backspace(); - Action::None - } - Action::CreateNextField => { - self.create_state.next_field(); - Action::None - } - Action::CreatePrevField => { - self.create_state.prev_field(); - Action::None - } - Action::CreateToggle => { - if self.create_state.current_field() == CreateFormField::ContractType { - self.create_state.toggle_contract_type(); - } - Action::None - } - Action::CreateSubmit => { - if self.create_state.is_valid() { - let name = self.create_state.name.clone(); - let description = self.create_state.description.clone(); - let contract_type = self.create_state.contract_type.clone(); - let repository_url = if self.create_state.repository_url.is_empty() { - None - } else { - Some(self.create_state.repository_url.clone()) - }; - self.input_mode = InputMode::Normal; - return Action::PerformCreateContract { - name, - description, - contract_type, - repository_url, - }; - } else { - self.status_message = Some("Name is required".to_string()); - } - Action::None - } - Action::CreateCancel => { - self.create_state = CreateContractState::new(); - self.input_mode = InputMode::Normal; - self.status_message = Some("Create cancelled".to_string()); - Action::None - } - Action::PerformCreateContract { name, description, contract_type, repository_url } => { - // Pass through to caller for API call - Action::PerformCreateContract { name, description, contract_type, repository_url } - } - Action::LoadRepoSuggestions => { - // Pass through to caller for API call - Action::LoadRepoSuggestions - } - Action::CreateNextSuggestion => { - self.create_state.next_suggestion(); - Action::None - } - Action::CreatePrevSuggestion => { - self.create_state.prev_suggestion(); - Action::None - } - Action::CreateApplySuggestion => { - self.create_state.apply_selected_suggestion(); - 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<String> { - self.pending_delete.and_then(|id| { - self.filtered_items.iter() - .find(|item| item.id == id) - .map(|item| item.name.clone()) - }) - } -} diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs deleted file mode 100644 index d5ca569..0000000 --- a/makima/src/daemon/tui/event.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! TUI event handling. - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use std::time::Duration; - -use super::app::{Action, App, CreateFormField, InputMode, ViewType}; - -/// Poll for events with timeout -pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { - if event::poll(timeout)? { - Ok(Some(event::read()?)) - } else { - Ok(None) - } -} - -/// Handle a key event and return the resulting action -pub fn handle_key_event(app: &App, key: KeyEvent) -> Action { - // Special handling for TaskOutput view - if app.view_type == ViewType::TaskOutput && app.input_mode == InputMode::Normal { - return handle_output_mode(key); - } - - match app.input_mode { - InputMode::Normal => handle_normal_mode(app, key), - InputMode::Search => handle_search_mode(key), - InputMode::Confirm => handle_confirm_mode(key), - InputMode::EditName | InputMode::EditDescription => handle_edit_mode(key), - InputMode::CreateName | InputMode::CreateDescription => handle_create_mode(app, key), - } -} - -/// Handle key events in normal navigation mode -fn handle_normal_mode(app: &App, key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => return Action::Quit, - _ => {} - } - } - - match key.code { - // Navigation - KeyCode::Up | KeyCode::Char('k') => Action::Up, - KeyCode::Down | KeyCode::Char('j') => Action::Down, - - // Drill-down into selected item (Enter or l for vim-style) - KeyCode::Enter | KeyCode::Char('l') => Action::DrillDown, - - // Go back (Backspace, h for vim-style, or Esc) - KeyCode::Backspace | KeyCode::Char('h') => Action::GoBack, - - // Other actions - KeyCode::Char('e') => Action::Edit, - KeyCode::Char('d') => Action::Delete, - KeyCode::Char('c') => Action::Navigate, // cd to worktree - - // New contract (only in contracts view) - KeyCode::Char('n') if app.view_type == ViewType::Contracts => Action::NewContract, - - // Preview toggle (space to show details in preview pane) - KeyCode::Char(' ') => Action::Select, - - // Search - KeyCode::Char('/') => Action::EnterSearch, - - // Refresh - KeyCode::Char('r') => Action::Refresh, - - // Quit (only q, Esc now goes back) - KeyCode::Char('q') => Action::Quit, - KeyCode::Esc => Action::GoBack, - - _ => Action::None, - } -} - -/// Handle key events in search mode -fn handle_search_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => return Action::Quit, - KeyCode::Char('u') => return Action::ClearSearch, - _ => {} - } - } - - match key.code { - // Exit search mode - KeyCode::Esc => Action::ExitSearch, - KeyCode::Enter => Action::ExitSearch, - - // Text input - KeyCode::Char(c) => Action::SearchChar(c), - KeyCode::Backspace => Action::SearchBackspace, - - // Navigation while searching - KeyCode::Up => Action::Up, - KeyCode::Down => Action::Down, - - _ => Action::None, - } -} - -/// Handle key events in confirmation mode -fn handle_confirm_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Confirm - KeyCode::Char('y') | KeyCode::Char('Y') => Action::ConfirmYes, - - // Cancel - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Action::ConfirmNo, - - _ => Action::None, - } -} - -/// Handle key events in task output view mode -fn handle_output_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Scroll - KeyCode::Up | KeyCode::Char('k') => Action::ScrollUp, - KeyCode::Down | KeyCode::Char('j') => Action::ScrollDown, - KeyCode::PageUp => Action::ScrollUp, - KeyCode::PageDown => Action::ScrollDown, - - // Scroll to bottom - KeyCode::Char('G') | KeyCode::End => Action::ScrollToBottom, - - // Go back (Backspace, h for vim-style, q, or Esc) - KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Esc => Action::GoBack, - KeyCode::Char('q') => Action::GoBack, - - // Refresh (re-connect WebSocket) - KeyCode::Char('r') => Action::Refresh, - - // Navigate to worktree - KeyCode::Char('c') => Action::Navigate, - - _ => Action::None, - } -} - -/// Handle key events in edit mode -fn handle_edit_mode(key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - match key.code { - // Save - KeyCode::Enter => Action::EditSave, - - // Cancel - KeyCode::Esc => Action::EditCancel, - - // Switch fields - KeyCode::Tab => Action::EditNextField, - - // Text input - KeyCode::Char(c) => Action::EditChar(c), - KeyCode::Backspace => Action::EditBackspace, - - _ => Action::None, - } -} - -/// Handle key events in create contract mode -fn handle_create_mode(app: &App, key: KeyEvent) -> Action { - // Check for Ctrl+C first - if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - return Action::Quit; - } - } - - let current_field = app.create_state.current_field(); - let has_suggestions = app.create_state.show_suggestions - && !app.create_state.repo_suggestions.is_empty(); - - // Allow Ctrl+N/Ctrl+P to navigate suggestions from any field - if has_suggestions && key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('n') => return Action::CreateNextSuggestion, - KeyCode::Char('p') => return Action::CreatePrevSuggestion, - _ => {} - } - } - - // Special handling when on Repository field with suggestions visible - let on_repo_field = current_field == CreateFormField::Repository; - if has_suggestions && on_repo_field { - match key.code { - // Up/Down navigate suggestions when on repo field - KeyCode::Up => return Action::CreatePrevSuggestion, - KeyCode::Down => return Action::CreateNextSuggestion, - // Enter applies suggestion instead of submitting form - KeyCode::Enter => return Action::CreateApplySuggestion, - _ => {} - } - } - - match key.code { - // Submit form - KeyCode::Enter => { - // If on contract type field, toggle instead of submit - if current_field == CreateFormField::ContractType { - Action::CreateToggle - } else { - Action::CreateSubmit - } - } - - // Cancel - KeyCode::Esc => Action::CreateCancel, - - // Navigate between fields - KeyCode::Tab => Action::CreateNextField, - KeyCode::BackTab => Action::CreatePrevField, - KeyCode::Up => Action::CreatePrevField, - KeyCode::Down => Action::CreateNextField, - - // Toggle for contract type field - KeyCode::Char(' ') if current_field == CreateFormField::ContractType => Action::CreateToggle, - KeyCode::Left if current_field == CreateFormField::ContractType => Action::CreateToggle, - KeyCode::Right if current_field == CreateFormField::ContractType => Action::CreateToggle, - - // Text input (for text fields) - KeyCode::Char(c) if current_field != CreateFormField::ContractType => Action::CreateChar(c), - KeyCode::Backspace if current_field != CreateFormField::ContractType => Action::CreateBackspace, - - _ => Action::None, - } -} - -/// Get help text for current mode -pub fn get_help_text(mode: InputMode) -> &'static str { - match mode { - InputMode::Normal => "j/k: nav | Enter: open | Esc/h: back | e: edit | d: del | n: new | /: search | q: quit", - InputMode::Search => "Type to search | Enter/Esc: exit search | Up/Down: navigate", - InputMode::Confirm => "y: confirm | n/Esc: cancel", - InputMode::EditName | InputMode::EditDescription => "Type to edit | Tab: switch field | Enter: save | Esc: cancel", - InputMode::CreateName | InputMode::CreateDescription => "Type to edit | Tab/↑↓: switch field | Enter: create | Esc: cancel", - } -} - -/// Get help text for output view -pub fn get_output_help_text() -> &'static str { - "j/k: scroll | G: bottom | c: cd | q/Esc: back | r: refresh" -} diff --git a/makima/src/daemon/tui/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs deleted file mode 100644 index 44c27ad..0000000 --- a/makima/src/daemon/tui/fuzzy.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Fuzzy matching wrapper for search functionality. -//! -//! This module provides a wrapper around the `fuzzy-matcher` crate's -//! `SkimMatcherV2` algorithm, offering: -//! -//! - Single-term fuzzy matching with score and matched indices -//! - Multi-term search (space-separated patterns) -//! - Recency-adjusted scoring for time-aware results -//! - Case-insensitive matching by default -//! -//! # Examples -//! -//! ``` -//! use makima::daemon::tui::fuzzy::FuzzyMatcher; -//! -//! let matcher = FuzzyMatcher::new(); -//! -//! // Single pattern matching -//! if let Some((score, indices)) = matcher.fuzzy_match("hello world", "hlo") { -//! println!("Score: {}, Matched positions: {:?}", score, indices); -//! } -//! -//! // Multi-term search -//! if let Some(score) = matcher.fuzzy_match_all("fix authentication bug", "fix bug") { -//! println!("All terms matched with score: {}", score); -//! } -//! ``` - -use fuzzy_matcher::skim::SkimMatcherV2; -use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait; - -/// Fuzzy matcher wrapper providing search functionality. -/// -/// Wraps the `SkimMatcherV2` algorithm which provides: -/// - Smart case matching (case-insensitive unless pattern has uppercase) -/// - Word boundary bonuses -/// - Consecutive character bonuses -pub struct FuzzyMatcher { - matcher: SkimMatcherV2, -} - -impl FuzzyMatcher { - /// Create a new fuzzy matcher with default settings. - pub fn new() -> Self { - Self { - matcher: SkimMatcherV2::default(), - } - } - - /// Match a pattern against a string, returning score and matched indices. - /// - /// Returns `Some((score, indices))` if the pattern matches, where: - /// - `score` is a relevance score (higher is better) - /// - `indices` are the positions of matched characters in the text - /// - /// Returns `None` if the pattern doesn't match the text. - /// - /// # Arguments - /// - /// * `text` - The text to search in - /// * `pattern` - The pattern to search for - pub fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i64, Vec<usize>)> { - self.matcher.fuzzy_indices(text, pattern) - } - - /// Match multiple patterns (space-separated) against a string. - /// - /// All patterns must match for the function to return a score. - /// The returned score is the sum of individual pattern scores. - /// - /// # Arguments - /// - /// * `text` - The text to search in - /// * `patterns` - Space-separated patterns (e.g., "fix bug" matches both "fix" and "bug") - /// - /// # Returns - /// - /// `Some(total_score)` if all patterns match, `None` otherwise. - pub fn fuzzy_match_all(&self, text: &str, patterns: &str) -> Option<i64> { - let patterns: Vec<&str> = patterns.split_whitespace().collect(); - - if patterns.is_empty() { - return Some(0); - } - - let mut total_score = 0i64; - - for pattern in patterns { - if let Some((score, _)) = self.matcher.fuzzy_indices(text, pattern) { - total_score += score; - } else { - return None; - } - } - - Some(total_score) - } - - /// Calculate a recency-adjusted score for time-aware sorting. - /// - /// Items with lower indices (more recent) receive a bonus to their score, - /// making them rank higher in search results. - /// - /// # Arguments - /// - /// * `base_score` - The original fuzzy match score - /// * `index` - The item's position in the list (0 = most recent) - /// * `total_items` - Total number of items in the list - /// - /// # Returns - /// - /// An adjusted score that factors in recency. - pub fn recency_adjusted_score(base_score: i64, index: usize, total_items: usize) -> i64 { - if total_items == 0 { - return base_score; - } - - // Recency bonus: items at the beginning get up to 20% bonus - // Formula: bonus = base_score * 0.2 * (1 - index/total_items) - let recency_factor = 1.0 - (index as f64 / total_items as f64); - let bonus = (base_score as f64 * 0.2 * recency_factor) as i64; - - base_score + bonus - } -} - -impl Default for FuzzyMatcher { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fuzzy_match_exact() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello world", "hello"); - assert!(result.is_some()); - } - - #[test] - fn test_fuzzy_match_partial() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("authentication", "auth"); - assert!(result.is_some()); - } - - #[test] - fn test_fuzzy_match_no_match() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello", "xyz"); - assert!(result.is_none()); - } - - #[test] - fn test_multi_term_search() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match_all("fix authentication bug", "fix bug"); - assert!(result.is_some()); - } - - #[test] - fn test_case_insensitive() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("Hello World", "hello"); - assert!(result.is_some()); - } - - #[test] - fn test_recency_bonus() { - // Earlier items (lower index) should get higher recency bonus - let score1 = FuzzyMatcher::recency_adjusted_score(100, 0, 50); - let score2 = FuzzyMatcher::recency_adjusted_score(100, 10, 50); - assert!(score1 > score2); - } - - #[test] - fn test_fuzzy_match_returns_indices() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match("hello world", "hlo"); - assert!(result.is_some()); - let (_, indices) = result.unwrap(); - // Should have matched 3 characters - assert_eq!(indices.len(), 3); - } - - #[test] - fn test_multi_term_empty_pattern() { - let matcher = FuzzyMatcher::new(); - let result = matcher.fuzzy_match_all("hello world", ""); - assert!(result.is_some()); - assert_eq!(result.unwrap(), 0); - } - - #[test] - fn test_multi_term_partial_match_fails() { - let matcher = FuzzyMatcher::new(); - // "xyz" doesn't match, so the whole search should fail - let result = matcher.fuzzy_match_all("fix authentication bug", "fix xyz"); - assert!(result.is_none()); - } - - #[test] - fn test_recency_bonus_edge_cases() { - // Zero total items should return base score - let score = FuzzyMatcher::recency_adjusted_score(100, 0, 0); - assert_eq!(score, 100); - - // Last item should get minimal bonus - let score_last = FuzzyMatcher::recency_adjusted_score(100, 49, 50); - let score_first = FuzzyMatcher::recency_adjusted_score(100, 0, 50); - assert!(score_first > score_last); - } -} diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs deleted file mode 100644 index e52b12a..0000000 --- a/makima/src/daemon/tui/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! TUI module for interactive browsing. -//! -//! This module provides an interactive Terminal User Interface (TUI) for -//! browsing and managing tasks, contracts, and files in the makima system. -//! -//! # Features -//! -//! - **Fuzzy Search**: Real-time filtering with the SkimMatcherV2 algorithm -//! - **Keyboard Navigation**: Vim-style keybindings (j/k) and arrow keys -//! - **Preview Pane**: Side-by-side view of item details -//! - **Multiple Views**: Browse tasks, contracts, or files - -pub mod app; -pub mod event; -pub mod fuzzy; -pub mod ui; -pub mod ws_client; - -pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState, CreateContractState, CreateFormField, RepositorySuggestion}; -pub use ws_client::{TuiWsClient, WsCommand, WsEvent, TaskOutputEvent}; -pub use fuzzy::FuzzyMatcher; - -use std::io; -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::prelude::*; -use ratatui::backend::CrosstermBackend; - -pub type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>; - -/// Run the TUI application -pub fn run(mut app: App) -> Result<Option<String>, Box<dyn std::error::Error>> { - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = ratatui::Terminal::new(backend)?; - - // Run the main loop - let result = run_app(&mut terminal, &mut app); - - // Cleanup terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - result -} - -fn run_app( - terminal: &mut Terminal, - app: &mut App, -) -> Result<Option<String>, Box<dyn std::error::Error>> { - use crossterm::event::Event; - use std::time::Duration; - - loop { - terminal.draw(|f| ui::render(f, app))?; - - // Poll for events with 100ms timeout - if let Some(evt) = event::poll_event(Duration::from_millis(100))? { - if let Event::Key(key) = evt { - let action = event::handle_key_event(app, key); - match action { - Action::Quit => break, - Action::OutputPath(path) => return Ok(Some(path)), - Action::None => {} - _ => { - let result = app.handle_action(action); - // Check if handle_action returned a special action - if let Action::OutputPath(path) = result { - return Ok(Some(path)); - } - } - } - } - } - - if app.should_quit { - break; - } - } - - Ok(None) -} - -/// Print a path to stdout (for cd integration) -pub fn print_path(path: &str) { - println!("{}", path); -} 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()) - } - } -} diff --git a/makima/src/daemon/tui/views/contracts.rs b/makima/src/daemon/tui/views/contracts.rs deleted file mode 100644 index 73b7c33..0000000 --- a/makima/src/daemon/tui/views/contracts.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Contracts view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load contracts from API -pub async fn load_contracts( - client: &ApiClient, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let result = client.list_contracts().await?; - - // Response is { "contracts": [...], "total": N } - let contracts = result - .0 - .get("contracts") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect()) - .unwrap_or_default(); - - Ok(contracts) -} - -/// Get full contract details for preview -pub async fn get_contract_preview( - _client: &ApiClient, - _contract_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - // TODO: Implement contract preview - Ok("Contract preview not yet implemented".to_string()) -} diff --git a/makima/src/daemon/tui/views/files.rs b/makima/src/daemon/tui/views/files.rs deleted file mode 100644 index e21a989..0000000 --- a/makima/src/daemon/tui/views/files.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Files view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load files from API -pub async fn load_files( - client: &ApiClient, - contract_id: Uuid, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let result = client.contract_files(contract_id).await?; - - // Parse JSON response into ListItem - let files: Vec<serde_json::Value> = serde_json::from_value(result.0)?; - - let items = files - .into_iter() - .filter_map(|f| { - let id_str = f.get("id")?.as_str()?; - let id = Uuid::parse_str(id_str).ok()?; - - Some(ListItem { - id, - name: f - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(), - status: None, - description: f - .get("description") - .and_then(|v| v.as_str()) - .map(String::from), - updated_at: f - .get("updatedAt") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - extra: f, - }) - }) - .collect(); - - Ok(items) -} - -/// Get full file details for preview -pub async fn get_file_preview( - client: &ApiClient, - contract_id: Uuid, - file_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - let result = client.contract_file(contract_id, file_id).await?; - let file: serde_json::Value = result.0; - - let name = file - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown"); - let description = file - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("-"); - - // Try to get body content - let body_preview = if let Some(body) = file.get("body") { - if let Some(body_array) = body.as_array() { - body_array - .iter() - .filter_map(|item| { - let text = item.get("text").and_then(|v| v.as_str())?; - Some(text.to_string()) - }) - .take(5) - .collect::<Vec<_>>() - .join("\n") - } else { - "-".to_string() - } - } else { - "-".to_string() - }; - - Ok(format!( - "Name: {}\nDescription: {}\n\nContent:\n{}", - name, description, body_preview - )) -} diff --git a/makima/src/daemon/tui/views/mod.rs b/makima/src/daemon/tui/views/mod.rs deleted file mode 100644 index 699b6df..0000000 --- a/makima/src/daemon/tui/views/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod contracts; -pub mod files; -pub mod tasks; diff --git a/makima/src/daemon/tui/views/tasks.rs b/makima/src/daemon/tui/views/tasks.rs deleted file mode 100644 index fd52b11..0000000 --- a/makima/src/daemon/tui/views/tasks.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Tasks view implementation. - -use uuid::Uuid; - -use crate::daemon::api::ApiClient; -use crate::daemon::tui::app::ListItem; - -/// Load tasks from API -pub async fn load_tasks( - client: &ApiClient, - contract_id: Option<Uuid>, -) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - let Some(contract_id) = contract_id else { - // TODO: Implement listing all tasks across contracts - return Ok(Vec::new()); - }; - - let result = client.supervisor_tasks(contract_id).await?; - - // Parse JSON response into ListItem - let tasks: Vec<serde_json::Value> = serde_json::from_value(result.0)?; - - let items = tasks - .into_iter() - .filter_map(|t| { - let id_str = t.get("id")?.as_str()?; - let id = Uuid::parse_str(id_str).ok()?; - - Some(ListItem { - id, - name: t - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("Unnamed") - .to_string(), - status: t.get("status").and_then(|v| v.as_str()).map(String::from), - description: t - .get("progressSummary") - .and_then(|v| v.as_str()) - .map(String::from), - updated_at: t - .get("updatedAt") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - extra: t, - }) - }) - .collect(); - - Ok(items) -} - -/// Get full task details for preview -pub async fn get_task_preview( - client: &ApiClient, - task_id: Uuid, -) -> Result<String, Box<dyn std::error::Error>> { - let result = client.supervisor_get_task(task_id).await?; - let task: serde_json::Value = result.0; - - Ok(format!( - "Name: {}\nStatus: {}\nPlan: {}\n\nProgress:\n{}", - task.get("name").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("status").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("plan").and_then(|v| v.as_str()).unwrap_or("-"), - task.get("progressSummary") - .and_then(|v| v.as_str()) - .unwrap_or("-"), - )) -} diff --git a/makima/src/daemon/tui/widgets/list_view.rs b/makima/src/daemon/tui/widgets/list_view.rs deleted file mode 100644 index ff8269a..0000000 --- a/makima/src/daemon/tui/widgets/list_view.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! List view widget with fuzzy match highlighting. - -use std::collections::HashSet; - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, List, ListItem, ListState}, -}; - -use crate::daemon::tui::app::{App, ViewMode}; - -/// Style for matched characters in search results -const MATCH_HIGHLIGHT_COLOR: Color = Color::Yellow; -const MATCH_HIGHLIGHT_MODIFIER: Modifier = Modifier::BOLD; - -/// Build a Line with highlighted characters based on matched indices -fn build_highlighted_name(name: &str, matched_indices: &[usize]) -> Vec<Span<'static>> { - if matched_indices.is_empty() { - return vec![Span::raw(name.to_string())]; - } - - let matched_set: HashSet<usize> = matched_indices.iter().cloned().collect(); - let mut spans = Vec::new(); - let mut current_run = String::new(); - let mut is_highlighted = false; - - for (byte_idx, ch) in name.char_indices() { - let should_highlight = matched_set.contains(&byte_idx); - - if should_highlight != is_highlighted { - // Flush current run - if !current_run.is_empty() { - if is_highlighted { - spans.push(Span::styled( - current_run.clone(), - Style::default() - .fg(MATCH_HIGHLIGHT_COLOR) - .add_modifier(MATCH_HIGHLIGHT_MODIFIER), - )); - } else { - spans.push(Span::raw(current_run.clone())); - } - current_run.clear(); - } - is_highlighted = should_highlight; - } - - current_run.push(ch); - } - - // Flush remaining - if !current_run.is_empty() { - if is_highlighted { - spans.push(Span::styled( - current_run, - Style::default() - .fg(MATCH_HIGHLIGHT_COLOR) - .add_modifier(MATCH_HIGHLIGHT_MODIFIER), - )); - } else { - spans.push(Span::raw(current_run)); - } - } - - spans -} - -/// Get status icon and color for an item -fn get_status_display(status: Option<&str>) -> (&'static str, Color) { - match status { - Some("running") => ("▸", Color::Green), - Some("done") => ("✓", Color::Blue), - Some("failed") => ("✗", Color::Red), - Some("pending") => ("○", Color::Yellow), - Some("paused") => ("⏸", Color::Cyan), - _ => (" ", Color::Gray), - } -} - -pub fn render(f: &mut Frame, area: Rect, app: &mut App) { - let items: Vec<ListItem> = app - .filtered_items - .iter() - .map(|filtered_item| { - let item = &app.items[filtered_item.index]; - let (status_icon, status_color) = get_status_display(item.status.as_deref()); - - // Build spans with highlighted matched characters - let mut spans = vec![Span::styled( - format!("{} ", status_icon), - Style::default().fg(status_color), - )]; - - // Add name with match highlighting - spans.extend(build_highlighted_name(&item.name, &filtered_item.matched_indices)); - - ListItem::new(Line::from(spans)) - }) - .collect(); - - let view_label = match app.view_mode { - ViewMode::Tasks => "Tasks", - ViewMode::Contracts => "Contracts", - ViewMode::Files => "Files", - }; - - let title = format!( - " {} ({}{}) ", - view_label, - app.filtered_items.len(), - if app.filtered_items.len() != app.items.len() { - format!("/{}", app.items.len()) - } else { - String::new() - } - ); - - let list = List::new(items) - .block(Block::default().title(title).borders(Borders::ALL)) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol("> "); - - let mut state = ListState::default(); - state.select(Some(app.selected_index)); - - f.render_stateful_widget(list, area, &mut state); -} diff --git a/makima/src/daemon/tui/widgets/mod.rs b/makima/src/daemon/tui/widgets/mod.rs deleted file mode 100644 index ddea546..0000000 --- a/makima/src/daemon/tui/widgets/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod list_view; -pub mod preview_pane; -pub mod search_input; -pub mod status_bar; diff --git a/makima/src/daemon/tui/widgets/preview_pane.rs b/makima/src/daemon/tui/widgets/preview_pane.rs deleted file mode 100644 index 84095d0..0000000 --- a/makima/src/daemon/tui/widgets/preview_pane.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Preview pane widget. - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, -}; - -use crate::daemon::tui::app::App; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let content = app - .preview_content - .as_deref() - .unwrap_or("No preview available"); - - let preview = Paragraph::new(content) - .block(Block::default().title(" Preview ").borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - - f.render_widget(preview, area); -} diff --git a/makima/src/daemon/tui/widgets/search_input.rs b/makima/src/daemon/tui/widgets/search_input.rs deleted file mode 100644 index 311b4f0..0000000 --- a/makima/src/daemon/tui/widgets/search_input.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Search input widget with match count and visual feedback. - -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::daemon::tui::app::{App, InputMode, ViewMode}; - -/// Color for the search bar when there are no matches -const NO_MATCH_COLOR: Color = Color::Red; -/// Color for the search bar when actively searching -const SEARCH_ACTIVE_COLOR: Color = Color::Yellow; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let view_label = match app.view_mode { - ViewMode::Tasks => "Tasks", - ViewMode::Contracts => "Contracts", - ViewMode::Files => "Files", - }; - - let (matched, total) = app.match_count(); - let has_no_matches = app.has_no_matches(); - let is_searching = matches!(app.input_mode, InputMode::Search); - let has_query = !app.search_query.is_empty(); - - // Determine border style based on state - let border_style = if has_no_matches { - Style::default().fg(NO_MATCH_COLOR) - } else if is_searching { - Style::default().fg(SEARCH_ACTIVE_COLOR) - } else { - Style::default() - }; - - // Build the search input content - let search_text = if app.search_query.is_empty() { - if is_searching { - " Type to search...".to_string() - } else { - " Press / to search".to_string() - } - } else { - format!(" {}", app.search_query) - }; - - // Build the title with match count - let title = if has_query { - if has_no_matches { - format!(" 🔍 Search [{}] - No matches ", view_label) - } else { - format!(" 🔍 Search [{}] - {}/{} matches ", view_label, matched, total) - } - } else { - format!(" 🔍 Search [{}] ", view_label) - }; - - // Create input text with appropriate style - let text_style = if app.search_query.is_empty() && !is_searching { - Style::default().fg(Color::DarkGray) - } else if has_no_matches { - Style::default().fg(NO_MATCH_COLOR) - } else { - Style::default() - }; - - let input = Paragraph::new(Span::styled(search_text, text_style)).block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - - f.render_widget(input, area); - - // Show cursor in search mode - if is_searching { - // Calculate cursor position based on actual search query length - let cursor_x = area.x + app.search_query.len() as u16 + 2; - f.set_cursor_position(Position::new(cursor_x, area.y + 1)); - } -} diff --git a/makima/src/daemon/tui/widgets/status_bar.rs b/makima/src/daemon/tui/widgets/status_bar.rs deleted file mode 100644 index 3357c58..0000000 --- a/makima/src/daemon/tui/widgets/status_bar.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Status bar widget. - -use ratatui::{prelude::*, widgets::Paragraph}; - -use crate::daemon::tui::app::{App, InputMode}; - -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let keybindings = match app.input_mode { - InputMode::Normal => { - "↑↓:Navigate Enter:View e:Edit d:Delete Tab:Preview /:Search q:Quit" - } - InputMode::Search => "Type to search Enter:Select Esc:Cancel", - InputMode::Confirm => "y:Confirm n:Cancel", - }; - - let status = Paragraph::new(keybindings).style(Style::default().bg(Color::DarkGray)); - - f.render_widget(status, area); -} diff --git a/makima/src/daemon/tui/ws_client.rs b/makima/src/daemon/tui/ws_client.rs deleted file mode 100644 index 3462467..0000000 --- a/makima/src/daemon/tui/ws_client.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! TUI WebSocket client for task output streaming. -//! -//! Uses a dedicated async thread to handle WebSocket communication, -//! bridging async/sync worlds via channels. - -use std::sync::mpsc as std_mpsc; -use std::thread; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use tokio::runtime::Runtime; -use tokio::sync::mpsc as tokio_mpsc; -use uuid::Uuid; - -/// Commands sent from TUI to WebSocket client -#[derive(Debug, Clone)] -pub enum WsCommand { - /// Subscribe to task output - Subscribe { task_id: Uuid }, - /// Unsubscribe from task output - Unsubscribe { task_id: Uuid }, - /// Shutdown the WebSocket client - Shutdown, -} - -/// Events sent from WebSocket client to TUI -#[derive(Debug, Clone)] -pub enum WsEvent { - /// WebSocket connected - Connected, - /// WebSocket disconnected - Disconnected, - /// WebSocket reconnecting - Reconnecting { attempt: u32 }, - /// Subscription confirmed - Subscribed { task_id: Uuid }, - /// Unsubscription confirmed - Unsubscribed { task_id: Uuid }, - /// Task output received - TaskOutput(TaskOutputEvent), - /// Error occurred - Error { message: String }, -} - -/// Task output event from server -#[derive(Debug, Clone)] -pub struct TaskOutputEvent { - pub task_id: Uuid, - pub message_type: String, - pub content: String, - pub tool_name: Option<String>, - pub tool_input: Option<serde_json::Value>, - pub is_error: Option<bool>, - pub cost_usd: Option<f64>, - pub duration_ms: Option<u64>, - pub is_partial: bool, -} - -/// Messages sent to the WebSocket server -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum ClientMessage { - SubscribeOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - UnsubscribeOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - }, -} - -/// Messages received from the WebSocket server -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum ServerMessage { - OutputSubscribed { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - OutputUnsubscribed { - #[serde(rename = "taskId")] - task_id: Uuid, - }, - TaskOutput { - #[serde(rename = "taskId")] - task_id: Uuid, - #[serde(rename = "messageType")] - message_type: String, - content: String, - #[serde(rename = "toolName")] - tool_name: Option<String>, - #[serde(rename = "toolInput")] - tool_input: Option<serde_json::Value>, - #[serde(rename = "isError")] - is_error: Option<bool>, - #[serde(rename = "costUsd")] - cost_usd: Option<f64>, - #[serde(rename = "durationMs")] - duration_ms: Option<u64>, - #[serde(rename = "isPartial")] - is_partial: bool, - }, - Error { - code: String, - message: String, - }, - // Other message types we don't care about - #[serde(other)] - Other, -} - -/// TUI WebSocket client handle -pub struct TuiWsClient { - /// Command sender to WebSocket thread - command_tx: tokio_mpsc::Sender<WsCommand>, - /// Event receiver from WebSocket thread - event_rx: std_mpsc::Receiver<WsEvent>, -} - -impl TuiWsClient { - /// Start a new WebSocket client in a dedicated thread - pub fn start(api_url: String, api_key: String) -> Self { - let (command_tx, command_rx) = tokio_mpsc::channel(32); - let (event_tx, event_rx) = std_mpsc::channel(); - - // Spawn as daemon thread so it doesn't block process exit - thread::Builder::new() - .name("ws-client".to_string()) - .spawn(move || { - let rt = match Runtime::new() { - Ok(rt) => rt, - Err(e) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("Failed to create tokio runtime: {}", e), - }); - return; - } - }; - rt.block_on(run_ws_client(api_url, api_key, command_rx, event_tx)); - }) - .ok(); - - Self { - command_tx, - event_rx, - } - } - - /// Send a command to the WebSocket client (non-blocking) - pub fn send(&self, command: WsCommand) { - // Use try_send to avoid blocking on shutdown - let _ = self.command_tx.try_send(command); - } - - /// Subscribe to task output - pub fn subscribe(&self, task_id: Uuid) { - self.send(WsCommand::Subscribe { task_id }); - } - - /// Unsubscribe from task output - pub fn unsubscribe(&self, task_id: Uuid) { - self.send(WsCommand::Unsubscribe { task_id }); - } - - /// Shutdown the WebSocket client - pub fn shutdown(&self) { - self.send(WsCommand::Shutdown); - } - - /// Try to receive an event (non-blocking) - pub fn try_recv(&self) -> Option<WsEvent> { - self.event_rx.try_recv().ok() - } - - /// Receive an event with timeout - pub fn recv_timeout(&self, timeout: Duration) -> Option<WsEvent> { - self.event_rx.recv_timeout(timeout).ok() - } -} - -impl Drop for TuiWsClient { - fn drop(&mut self) { - // Try to send shutdown command, but don't wait - let _ = self.command_tx.try_send(WsCommand::Shutdown); - } -} - -/// WebSocket client main loop -async fn run_ws_client( - api_url: String, - api_key: String, - mut command_rx: tokio_mpsc::Receiver<WsCommand>, - event_tx: std_mpsc::Sender<WsEvent>, -) { - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::{connect_async, tungstenite::client::IntoClientRequest, tungstenite::Message}; - - // Build WebSocket URL from HTTP URL - let ws_url = api_url - .replace("https://", "wss://") - .replace("http://", "ws://"); - let ws_url = format!("{}/api/v1/mesh/tasks/subscribe", ws_url); - - let mut reconnect_attempt = 0u32; - let max_reconnect_delay = Duration::from_secs(30); - let initial_delay = Duration::from_secs(1); - - loop { - // Build request with API key header - let mut request = match ws_url.clone().into_client_request() { - Ok(r) => r, - Err(e) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("Invalid URL: {}", e), - }); - return; - } - }; - - // Send both headers - server will try tool key first, then API key - if let Ok(header_value) = api_key.parse() { - request.headers_mut().insert("x-makima-tool-key", header_value); - } - if let Ok(header_value) = api_key.parse() { - request.headers_mut().insert("x-makima-api-key", header_value); - } - - if reconnect_attempt > 0 { - let _ = event_tx.send(WsEvent::Reconnecting { - attempt: reconnect_attempt, - }); - - // Exponential backoff - let delay = std::cmp::min( - initial_delay * 2u32.saturating_pow(reconnect_attempt - 1), - max_reconnect_delay, - ); - tokio::time::sleep(delay).await; - } - - // Try to connect - let (ws_stream, _) = match connect_async(request).await { - Ok(result) => { - reconnect_attempt = 0; - let _ = event_tx.send(WsEvent::Connected); - result - } - Err(e) => { - reconnect_attempt += 1; - let _ = event_tx.send(WsEvent::Error { - message: format!("Connection failed: {}", e), - }); - continue; - } - }; - - let (mut write, mut read) = ws_stream.split(); - - // Main message loop - loop { - tokio::select! { - // Handle commands from TUI - cmd = command_rx.recv() => { - match cmd { - Some(WsCommand::Subscribe { task_id }) => { - let msg = ClientMessage::SubscribeOutput { task_id }; - if let Ok(json) = serde_json::to_string(&msg) { - let _ = write.send(Message::Text(json)).await; - } - } - Some(WsCommand::Unsubscribe { task_id }) => { - let msg = ClientMessage::UnsubscribeOutput { task_id }; - if let Ok(json) = serde_json::to_string(&msg) { - let _ = write.send(Message::Text(json)).await; - } - } - Some(WsCommand::Shutdown) | None => { - let _ = write.close().await; - return; - } - } - } - - // Handle messages from server - msg = read.next() => { - match msg { - Some(Ok(Message::Text(text))) => { - if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) { - match server_msg { - ServerMessage::OutputSubscribed { task_id } => { - let _ = event_tx.send(WsEvent::Subscribed { task_id }); - } - ServerMessage::OutputUnsubscribed { task_id } => { - let _ = event_tx.send(WsEvent::Unsubscribed { task_id }); - } - ServerMessage::TaskOutput { - task_id, - message_type, - content, - tool_name, - tool_input, - is_error, - cost_usd, - duration_ms, - is_partial, - } => { - let _ = event_tx.send(WsEvent::TaskOutput(TaskOutputEvent { - task_id, - message_type, - content, - tool_name, - tool_input, - is_error, - cost_usd, - duration_ms, - is_partial, - })); - } - ServerMessage::Error { code, message } => { - let _ = event_tx.send(WsEvent::Error { - message: format!("{}: {}", code, message), - }); - } - ServerMessage::Other => { - // Ignore other message types - } - } - } - } - Some(Ok(Message::Ping(data))) => { - let _ = write.send(Message::Pong(data)).await; - } - Some(Ok(Message::Close(_))) | None => { - let _ = event_tx.send(WsEvent::Disconnected); - reconnect_attempt += 1; - break; // Reconnect - } - Some(Err(e)) => { - let _ = event_tx.send(WsEvent::Error { - message: format!("WebSocket error: {}", e), - }); - let _ = event_tx.send(WsEvent::Disconnected); - reconnect_attempt += 1; - break; // Reconnect - } - _ => {} - } - } - } - } - } -} |
