summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/fuzzy.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-19 13:47:32 +0000
committerGitHub <noreply@github.com>2026-01-19 13:47:32 +0000
commit0833fb1f30c0c3b920157deb882e0e902c3af02a (patch)
tree45110fb8cb9277dfbaccfeb53ed9c1f76975022b /makima/src/daemon/tui/fuzzy.rs
parent786510379bed060db2b3742b7dfca671552d2c34 (diff)
downloadsoryu-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.rs217
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);
+ }
+}