diff options
Diffstat (limited to 'makima/src/daemon')
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 13 | ||||
| -rw-r--r-- | makima/src/daemon/cli/view.rs | 128 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 462 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 118 | ||||
| -rw-r--r-- | makima/src/daemon/tui/fuzzy.rs | 217 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 96 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 209 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/contracts.rs | 24 | ||||
| -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 |
17 files changed, 1687 insertions, 1 deletions
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index cde6e16..842fa63 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -4,6 +4,7 @@ pub mod contract; pub mod daemon; pub mod server; pub mod supervisor; +pub mod view; use clap::{Parser, Subcommand}; @@ -11,6 +12,7 @@ pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use server::ServerArgs; pub use supervisor::SupervisorArgs; +pub use view::{ViewArgs, ViewCommand}; /// Makima - unified CLI for server, daemon, and task management. #[derive(Parser, Debug)] @@ -36,6 +38,17 @@ pub enum Commands { /// Contract commands for task-contract interaction #[command(subcommand)] Contract(ContractCommand), + + /// Interactive TUI browser for tasks, contracts, and files + /// + /// Provides a fuzzy-searchable interface with keyboard navigation. + /// + /// Keyboard shortcuts: + /// ↑/k: Move up ↓/j: Move down Enter: Select + /// /: Search Tab: Toggle preview q: Quit + /// e: Edit d: Delete c: cd to worktree + #[command(subcommand)] + View(ViewCommand), } /// Supervisor subcommands for contract orchestration. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs new file mode 100644 index 0000000..f42c490 --- /dev/null +++ b/makima/src/daemon/cli/view.rs @@ -0,0 +1,128 @@ +//! View subcommand - interactive TUI browser for tasks, contracts, and files. +//! +//! The `makima view` command provides an interactive Terminal User Interface (TUI) +//! for browsing and managing makima entities. It features fuzzy search filtering, +//! keyboard navigation, and quick actions. +//! +//! # Usage +//! +//! ```bash +//! # Browse tasks interactively +//! makima view tasks +//! +//! # Browse contracts with an initial search query +//! makima view contracts "my project" +//! +//! # Browse files without preview pane +//! makima view files --no-preview +//! +//! # Browse tasks for a specific contract +//! makima view tasks --contract-id <uuid> +//! +//! # Change directory to selected task's worktree +//! cd $(makima view tasks) +//! ``` +//! +//! # Keyboard Shortcuts +//! +//! | Key | Action | +//! |-------------|---------------------------| +//! | `↑` / `k` | Move selection up | +//! | `↓` / `j` | Move selection down | +//! | `Enter` | View/select item | +//! | `e` | Open in editor ($EDITOR) | +//! | `d` | Delete item (with confirm)| +//! | `Tab` | Toggle preview pane | +//! | `/` | Focus search input | +//! | `Esc` | Clear search / cancel | +//! | `q` | Quit | +//! | `c` | Navigate to worktree (cd) | +//! | `Ctrl+r` | Refresh data | +//! | `?` | Show help | +//! +//! # Features +//! +//! - **Fuzzy Search**: Type to filter items in real-time +//! - **Multi-term Search**: Use space-separated terms (e.g., "fix bug") +//! - **Recency Sorting**: Recent items appear higher in results +//! - **Preview Pane**: See item details without leaving the list +//! - **Status Indicators**: Visual icons for task states + +use clap::{Args, Subcommand}; +use uuid::Uuid; + +/// Interactive TUI browser for tasks, contracts, and files. +/// +/// Provides a fuzzy-searchable interface for browsing and managing +/// makima entities with keyboard navigation and quick actions. +/// +/// # Examples +/// +/// Browse tasks: +/// ```bash +/// makima view tasks +/// ``` +/// +/// Browse with initial search: +/// ```bash +/// makima view contracts "auth" +/// ``` +#[derive(Subcommand, Debug)] +pub enum ViewCommand { + /// Browse tasks interactively + /// + /// Shows all tasks for the current contract with status indicators, + /// fuzzy search filtering, and quick actions. + Tasks(ViewArgs), + + /// Browse contracts interactively + /// + /// Lists all contracts with their phase, status, and task counts. + Contracts(ViewArgs), + + /// Browse files interactively + /// + /// Shows contract files with preview of their content. + Files(ViewArgs), +} + +/// Common arguments for view commands. +/// +/// These arguments are shared across all view subcommands (tasks, contracts, files). +#[derive(Args, Debug, Clone)] +pub struct ViewArgs { + /// API URL for the makima server + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY")] + pub api_key: String, + + /// Contract ID to filter results (optional) + /// + /// When specified, only shows items belonging to this contract. + #[arg(long, env = "MAKIMA_CONTRACT_ID")] + pub contract_id: Option<Uuid>, + + /// 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, + + /// Sort order for results + /// + /// - `recent`: Sort by last updated time (default) + /// - `name`: Sort alphabetically by name + /// - `status`: Group by status, then by name + #[arg(long, default_value = "recent")] + pub sort: String, +} diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index d7ec3f0..c348838 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -5,6 +5,7 @@ //! - `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 pub mod api; pub mod cli; @@ -14,9 +15,10 @@ pub mod error; pub mod process; pub mod task; pub mod temp; +pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand}; +pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand}; 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 new file mode 100644 index 0000000..a2c82a2 --- /dev/null +++ b/makima/src/daemon/tui/app.rs @@ -0,0 +1,462 @@ +//! TUI application state and logic. + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use serde_json::Value; +use uuid::Uuid; + +/// Available views/resource types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewType { + Tasks, + Contracts, + Files, +} + +impl ViewType { + pub fn as_str(&self) -> &'static str { + match self { + ViewType::Tasks => "tasks", + ViewType::Contracts => "contracts", + ViewType::Files => "files", + } + } +} + +/// Input mode for the TUI +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMode { + /// Normal navigation mode + Normal, + /// Fuzzy search mode + Search, + /// Confirmation dialog (e.g., for delete) + Confirm, +} + +/// Actions that can be performed +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + /// Do nothing + None, + /// Move selection up + Up, + /// Move selection down + Down, + /// Select current item (show details) + Select, + /// Edit the selected item (open in editor) + Edit, + /// Delete the selected item + Delete, + /// Navigate to worktree (output path and exit) + Navigate, + /// Confirm pending action + ConfirmYes, + /// Cancel pending action + ConfirmNo, + /// Enter search mode + EnterSearch, + /// Exit search mode + ExitSearch, + /// Add character to search + SearchChar(char), + /// Backspace in search + SearchBackspace, + /// Clear search + ClearSearch, + /// Quit the application + Quit, + /// Output a path to stdout and exit (for cd integration) + OutputPath(String), + /// Launch editor with path + LaunchEditor(String), + /// Refresh data + Refresh, +} + +/// A displayable item in the TUI +#[derive(Debug, Clone)] +pub struct ListItem { + pub id: Uuid, + pub name: String, + pub status: Option<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::Tasks => { + lines.push(format!("╭─ Task Details ─────────────────────────")); + lines.push(format!("│ Name: {}", self.name)); + lines.push(format!("│ ID: {}", self.id)); + if let Some(ref status) = self.status { + lines.push(format!("│ Status: {}", status)); + } + if let Some(ref desc) = self.description { + lines.push(format!("│ Plan: {}", desc)); + } + if let Some(path) = self.get_worktree_path() { + lines.push(format!("│ Worktree: {}", path)); + } + // Add progress if available + if let Some(progress) = self.extra.get("progress").and_then(|v| v.as_str()) { + lines.push(format!("│ Progress: {}", progress)); + } + if let Some(error) = self.extra.get("error").and_then(|v| v.as_str()) { + lines.push(format!("│ Error: {}", error)); + } + lines.push(format!("╰────────────────────────────────────────")); + } + ViewType::Contracts => { + lines.push(format!("╭─ Contract Details ─────────────────────")); + lines.push(format!("│ Name: {}", self.name)); + lines.push(format!("│ ID: {}", self.id)); + if let Some(ref status) = self.status { + lines.push(format!("│ Phase: {}", status)); + } + if let Some(ref desc) = self.description { + lines.push(format!("│ Description: {}", desc)); + } + lines.push(format!("╰────────────────────────────────────────")); + } + ViewType::Files => { + lines.push(format!("╭─ File Details ─────────────────────────")); + lines.push(format!("│ Name: {}", self.name)); + lines.push(format!("│ ID: {}", self.id)); + if let Some(ref desc) = self.description { + lines.push(format!("│ {}", desc)); + } + // Add content preview if available + if let Some(content) = self.extra.get("content").and_then(|v| v.as_str()) { + lines.push(format!("│")); + lines.push(format!("│ Content:")); + for line in content.lines().take(10) { + lines.push(format!("│ {}", line)); + } + if content.lines().count() > 10 { + lines.push(format!("│ ... ({} more lines)", content.lines().count() - 10)); + } + } + lines.push(format!("╰────────────────────────────────────────")); + } + } + + lines.join("\n") + } +} + +/// TUI Application state +pub struct App { + /// Current view type + pub view_type: ViewType, + /// All items (unfiltered) + pub items: Vec<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>, + /// Status message + pub status_message: Option<String>, + /// Whether the app should quit + pub should_quit: bool, + /// Action to return when exiting (for OutputPath, LaunchEditor) + pub exit_action: Option<Action>, + /// Contract ID (for API calls) + pub contract_id: Option<Uuid>, +} + +impl App { + pub fn new(view_type: ViewType) -> Self { + Self { + view_type, + items: Vec::new(), + filtered_items: Vec::new(), + selected_index: 0, + input_mode: InputMode::Normal, + search_query: String::new(), + matcher: SkimMatcherV2::default(), + preview_content: String::new(), + preview_visible: false, + pending_delete: None, + status_message: None, + should_quit: false, + exit_action: None, + contract_id: None, + } + } + + /// Set items and update filtered list + pub fn set_items(&mut self, items: Vec<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::Edit => { + // Get worktree path and signal to launch editor + if let Some(item) = self.selected_item() { + if let Some(path) = item.get_worktree_path() { + self.should_quit = true; + self.exit_action = Some(Action::LaunchEditor(path.clone())); + return Action::LaunchEditor(path); + } else { + self.status_message = Some("No worktree path for this item".to_string()); + } + } + Action::None + } + Action::Delete => { + // First press: enter confirm mode + // Clone the values we need to avoid borrow issues + if let Some(item) = self.selected_item() { + let id = item.id; + let name = item.name.clone(); + self.pending_delete = Some(id); + self.input_mode = InputMode::Confirm; + self.status_message = Some(format!("Delete '{}'? (y/n)", name)); + } + Action::None + } + Action::Navigate => { + // Get worktree path and output it + if let Some(item) = self.selected_item() { + if let Some(path) = item.get_worktree_path() { + self.should_quit = true; + self.exit_action = Some(Action::OutputPath(path.clone())); + return Action::OutputPath(path); + } else { + self.status_message = Some("No worktree path for this item".to_string()); + } + } + Action::None + } + Action::ConfirmYes => { + if self.input_mode == InputMode::Confirm { + if let Some(_delete_id) = self.pending_delete.take() { + // TODO: Make API call to delete the item + // For now, just show status + self.status_message = Some("Delete confirmed (API call not implemented)".to_string()); + } + self.input_mode = InputMode::Normal; + } + Action::None + } + Action::ConfirmNo => { + if self.input_mode == InputMode::Confirm { + self.pending_delete = None; + self.input_mode = InputMode::Normal; + self.status_message = Some("Delete cancelled".to_string()); + } + Action::None + } + Action::EnterSearch => { + self.input_mode = InputMode::Search; + Action::None + } + Action::ExitSearch => { + self.input_mode = InputMode::Normal; + Action::None + } + Action::SearchChar(c) => { + self.search_query.push(c); + self.update_filtered_items(); + Action::None + } + Action::SearchBackspace => { + self.search_query.pop(); + self.update_filtered_items(); + Action::None + } + Action::ClearSearch => { + self.search_query.clear(); + self.update_filtered_items(); + Action::None + } + Action::Quit => { + self.should_quit = true; + Action::Quit + } + Action::Refresh => { + // Signal to caller to refresh data + Action::Refresh + } + Action::OutputPath(path) => { + self.should_quit = true; + self.exit_action = Some(Action::OutputPath(path.clone())); + Action::OutputPath(path) + } + Action::LaunchEditor(path) => { + self.should_quit = true; + self.exit_action = Some(Action::LaunchEditor(path.clone())); + Action::LaunchEditor(path) + } + Action::None => Action::None, + } + } + + /// Get the name of the item being deleted (for confirmation dialog) + pub fn get_pending_delete_name(&self) -> Option<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 new file mode 100644 index 0000000..12a6890 --- /dev/null +++ b/makima/src/daemon/tui/event.rs @@ -0,0 +1,118 @@ +//! TUI event handling. + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +use super::app::{Action, App, InputMode}; + +/// 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 { + match app.input_mode { + InputMode::Normal => handle_normal_mode(key), + InputMode::Search => handle_search_mode(key), + InputMode::Confirm => handle_confirm_mode(key), + } +} + +/// Handle key events in normal navigation mode +fn handle_normal_mode(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, + + // Actions + KeyCode::Enter => Action::Select, + KeyCode::Char('e') => Action::Edit, + KeyCode::Char('d') => Action::Delete, + KeyCode::Char('c') => Action::Navigate, // cd to worktree + + // Search + KeyCode::Char('/') => Action::EnterSearch, + + // Preview toggle (space to toggle preview visibility) + KeyCode::Char(' ') => Action::Select, + + // Refresh + KeyCode::Char('r') => Action::Refresh, + + // Quit + KeyCode::Char('q') | KeyCode::Esc => Action::Quit, + + _ => 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, + } +} + +/// Get help text for current mode +pub fn get_help_text(mode: InputMode) -> &'static str { + match mode { + InputMode::Normal => "j/k: navigate | Enter: details | e: edit | d: delete | c: cd | /: search | q: quit", + InputMode::Search => "Type to search | Enter/Esc: exit search | Up/Down: navigate", + InputMode::Confirm => "y: confirm | n/Esc: cancel", + } +} diff --git a/makima/src/daemon/tui/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs new file mode 100644 index 0000000..44c27ad --- /dev/null +++ b/makima/src/daemon/tui/fuzzy.rs @@ -0,0 +1,217 @@ +//! 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 new file mode 100644 index 0000000..fd1d44d --- /dev/null +++ b/makima/src/daemon/tui/mod.rs @@ -0,0 +1,96 @@ +//! 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 use app::{App, ListItem, ViewType, InputMode, Action}; +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 new file mode 100644 index 0000000..4003344 --- /dev/null +++ b/makima/src/daemon/tui/ui.rs @@ -0,0 +1,209 @@ +//! 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, InputMode, ViewType}; +use super::event::get_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 header with title and search bar +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let title = match app.view_type { + ViewType::Tasks => "Tasks", + ViewType::Contracts => "Contracts", + ViewType::Files => "Files", + }; + + let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { + format!("{} [Search: {}]", title, app.search_query) + } else { + format!("{} ({} items)", title, 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) { + 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) { + let help_text = get_help_text(app.input_mode); + + let status_text = app.status_message + .as_ref() + .map(|s| format!(" | {}", s)) + .unwrap_or_default(); + + let footer_text = format!("{}{}", help_text, 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); +} diff --git a/makima/src/daemon/tui/views/contracts.rs b/makima/src/daemon/tui/views/contracts.rs new file mode 100644 index 0000000..e2219b7 --- /dev/null +++ b/makima/src/daemon/tui/views/contracts.rs @@ -0,0 +1,24 @@ +//! 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>> { + // TODO: Implement listing all contracts + // This would require a new API endpoint + Ok(Vec::new()) +} + +/// 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 new file mode 100644 index 0000000..e21a989 --- /dev/null +++ b/makima/src/daemon/tui/views/files.rs @@ -0,0 +1,90 @@ +//! 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 new file mode 100644 index 0000000..699b6df --- /dev/null +++ b/makima/src/daemon/tui/views/mod.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..fd52b11 --- /dev/null +++ b/makima/src/daemon/tui/views/tasks.rs @@ -0,0 +1,71 @@ +//! 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 new file mode 100644 index 0000000..ff8269a --- /dev/null +++ b/makima/src/daemon/tui/widgets/list_view.rs @@ -0,0 +1,127 @@ +//! 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 new file mode 100644 index 0000000..ddea546 --- /dev/null +++ b/makima/src/daemon/tui/widgets/mod.rs @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..84095d0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/preview_pane.rs @@ -0,0 +1,21 @@ +//! 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 new file mode 100644 index 0000000..311b4f0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/search_input.rs @@ -0,0 +1,82 @@ +//! 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 new file mode 100644 index 0000000..3357c58 --- /dev/null +++ b/makima/src/daemon/tui/widgets/status_bar.rs @@ -0,0 +1,19 @@ +//! 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); +} |
