diff options
| author | soryu <soryu@soryu.co> | 2026-05-18 01:21:30 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-18 01:21:30 +0100 |
| commit | f240675da99bc7705e473b8f70a2628812aa4c10 (patch) | |
| tree | 3ee2d24b431ccb8cd1a3013c86b34a5782a3e224 /makima/src/daemon | |
| parent | 0d996cf7590e3e52f424859c7d6f0e68640f119e (diff) | |
| download | soryu-f240675da99bc7705e473b8f70a2628812aa4c10.tar.gz soryu-f240675da99bc7705e473b8f70a2628812aa4c10.zip | |
The contracts table, supervisor task type, and all their backing
machinery have been inert for several PRs. The directives system reads
its own active contract body for spec text, and PR #135 removed the
last LLM surface that spawned supervisors.
This PR wipes the dead surface in one shot — the user authorised a DB
wipe, so the migration drops every legacy table with CASCADE rather
than carrying forward stub rows. Net change: −12k LOC across handlers,
repository, state, models, the TUI, and the listen module.
What's gone:
- contracts, contract_chat_*, contract_events, contract_repositories,
contract_type_templates tables.
- supervisor_states, supervisor_heartbeats tables.
- mesh_chat_conversations, mesh_chat_messages tables.
- tasks.contract_id/is_supervisor/supervisor_task_id/supervisor_worktree_task_id columns.
- directive_steps.contract_id/contract_type columns.
- files.contract_id/contract_phase columns.
- history_events.contract_id/phase columns.
- The Contract/Supervisor/MeshChat handler + model + repository
surface, plus the daemon TUI views that read them.
- The standalone listen.rs websocket handler (orphaned with the LLM).
What stays:
- mesh_supervisor handler: trimmed to just the questions + orders
backchannel used by `makima directive ask` / `create-order` (kept
the URL prefix for CLI client compat).
- directive_documents (the user-facing "contracts" surface).
- pending_questions in-memory state for the directive Ask flow.
cargo check, cargo test --lib (68 passed), tsc, and vite build all
clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 - } - _ => {} - } - } - } - } - } -} |
