diff options
| author | soryu <soryu@soryu.co> | 2026-01-19 13:47:32 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-19 13:47:32 +0000 |
| commit | 0833fb1f30c0c3b920157deb882e0e902c3af02a (patch) | |
| tree | 45110fb8cb9277dfbaccfeb53ed9c1f76975022b /makima/src/daemon/tui/fuzzy.rs | |
| parent | 786510379bed060db2b3742b7dfca671552d2c34 (diff) | |
| download | soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.tar.gz soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.zip | |
Add interactive TUI browser for tasks, contracts, and files (makima view) (#7)
* feat(tui): Implement fuzzy search with real-time filtering and highlighting
Adds comprehensive fuzzy search functionality to the TUI browser:
## Fuzzy Matching (fuzzy.rs)
- FuzzyMatcher wrapper using SkimMatcherV2 from fuzzy-matcher crate
- fuzzy_match() returns score and matched character indices
- fuzzy_match_all() supports multi-term search (space-separated)
- Recency-aware scoring to boost recent items in results
- Unit tests for all matching scenarios
## App State (app.rs)
- FilteredItem struct with index, score, and matched_indices
- apply_filter() uses fuzzy matching with score-based sorting
- match_count() and has_no_matches() helper methods
- Results sorted by match score (highest first)
## List View (list_view.rs)
- Highlighted matched characters in search results
- Yellow bold styling for matched chars
- Status icons with color coding
## Search Input (search_input.rs)
- Real-time match count display (X/Y matches)
- Visual feedback for no matches (red border)
- Placeholder text when search is empty
- Active search mode indication (yellow border)
## Event Handling (event.rs)
- Arrow key navigation while in search mode
- Ctrl+K/J for vim-style navigation during search
- Delete key support alongside backspace
- Ctrl+U to clear search query
- Tab toggles preview while searching
- Escape clears search and exits search mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:20:34 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:31:19 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:39:07 UTC
* fix(tui): Fix module exports and main binary integration
- Update mod.rs to properly export app, event, fuzzy, and ui modules
- Add run() function for TUI entry point
- Fix run_view() to use ViewCommand enum instead of ViewArgs
- Fix event handling to use poll_event and handle_key_event
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/tui/fuzzy.rs')
| -rw-r--r-- | makima/src/daemon/tui/fuzzy.rs | 217 |
1 files changed, 217 insertions, 0 deletions
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); + } +} |
