diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index d180427..80745b7 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -13,6 +13,7 @@ pub mod emulators; pub mod fast_forward; pub mod input; pub mod keymap; +pub mod matcher; pub mod overlays; pub mod palette; pub mod pane; diff --git a/crates/maestro-tui/src/matcher.rs b/crates/maestro-tui/src/matcher.rs new file mode 100644 index 0000000..e552b17 --- /dev/null +++ b/crates/maestro-tui/src/matcher.rs @@ -0,0 +1,750 @@ +//! Pure match-and-rank function for the `ctrl-k` command bar. +//! +//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a +//! candidate is", "Match and rank", "Fixtures"). Must produce the same order +//! and scores as `web/src/matcher.ts` for the same input. + +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateKind { + Session, + Command, + Directory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MatchFieldName { + Name, + Directory, + Command, + Tool, + Label, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionCandidate { + pub name: String, + pub directory: Option, + pub command: Option, + pub tool: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCandidate { + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCandidate { + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchCandidate { + Session(SessionCandidate), + Command(CommandCandidate), + Directory(DirectoryCandidate), +} + +pub type MatchRange = (usize, usize); + +#[derive(Debug, PartialEq, Eq)] +pub struct MatchResult<'c> { + pub candidate: &'c MatchCandidate, + pub score: i32, + pub field: Option, + pub ranges: Vec, +} + +struct Field<'c> { + name: MatchFieldName, + text: &'c str, + weight: i32, +} + +impl MatchCandidate { + fn kind(&self) -> CandidateKind { + match self { + MatchCandidate::Session(_) => CandidateKind::Session, + MatchCandidate::Command(_) => CandidateKind::Command, + MatchCandidate::Directory(_) => CandidateKind::Directory, + } + } + + fn fields(&self) -> Vec> { + match self { + MatchCandidate::Session(session) => { + let mut fields = vec![Field { + name: MatchFieldName::Name, + text: &session.name, + weight: 0, + }]; + if let Some(directory) = &session.directory { + fields.push(Field { + name: MatchFieldName::Directory, + text: directory, + weight: -24, + }); + } + if let Some(command) = &session.command { + fields.push(Field { + name: MatchFieldName::Command, + text: command, + weight: -32, + }); + } + if let Some(tool) = &session.tool { + fields.push(Field { + name: MatchFieldName::Tool, + text: tool, + weight: -40, + }); + } + fields + } + MatchCandidate::Command(command) => vec![Field { + name: MatchFieldName::Label, + text: &command.label, + weight: 0, + }], + MatchCandidate::Directory(directory) => vec![Field { + name: MatchFieldName::Path, + text: &directory.path, + weight: 0, + }], + } + } +} + +fn kind_order(kind: CandidateKind) -> u8 { + match kind { + CandidateKind::Session => 0, + CandidateKind::Command => 1, + CandidateKind::Directory => 2, + } +} + +fn lower_char(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +fn lower_chars(chars: &[char]) -> Vec { + chars.iter().copied().map(lower_char).collect() +} + +fn normalize_query(query: &str) -> Vec { + query + .chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':']; + +fn boundary_bonus(text: &[char], position: usize) -> i32 { + if position == 0 { + return 18; + } + + let previous = text[position - 1]; + if previous == ' ' || SEPARATORS.contains(&previous) { + return 16; + } + + let current = text[position]; + if current.is_uppercase() && previous.is_lowercase() { + return 12; + } + + let is_digit = current.is_ascii_digit(); + let previous_is_digit = previous.is_ascii_digit(); + if is_digit && !previous_is_digit { + return 12; + } + + 0 +} + +fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 { + let gap = position as isize - previous_position - 1; + 16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 } +} + +#[derive(Clone)] +struct Embedding { + score: i32, + positions: Vec, +} + +fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool { + let Some(incumbent) = incumbent else { + return true; + }; + if candidate.score != incumbent.score { + return candidate.score > incumbent.score; + } + for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) { + if c != i { + return c < i; + } + } + false +} + +/// The dynamic program over `(query index, text position)` described in +/// "Which embedding": greedy leftmost-first matching is insufficient (e.g. +/// `mtu` against `maestro-tui`), so every reachable position at each query +/// index carries its best score and position vector forward. +fn best_embedding(query: &[char], text: &[char]) -> Option { + let lower_text = lower_chars(text); + let mut previous_states: HashMap = HashMap::new(); + + for (query_index, &qc) in query.iter().enumerate() { + let mut states: HashMap = HashMap::new(); + + for (position, &text_char) in lower_text.iter().enumerate() { + if text_char != qc { + continue; + } + + if query_index == 0 { + states.insert( + position, + Embedding { + score: contribution(text, position, -1), + positions: vec![position], + }, + ); + continue; + } + + for (&previous_position, previous) in previous_states.iter() { + if previous_position >= position { + continue; + } + let mut positions = previous.positions.clone(); + positions.push(position); + let candidate = Embedding { + score: previous.score + + contribution(text, position, previous_position as isize), + positions, + }; + if is_better_embedding(&candidate, states.get(&position)) { + states.insert(position, candidate); + } + } + } + + if states.is_empty() { + return None; + } + previous_states = states; + } + + let mut best: Option = None; + for embedding in previous_states.into_values() { + if is_better_embedding(&embedding, best.as_ref()) { + best = Some(embedding); + } + } + best +} + +fn to_ranges(positions: &[usize]) -> Vec { + let mut ranges: Vec = Vec::new(); + for &position in positions { + if let Some(last) = ranges.last_mut() { + if last.1 == position { + last.1 = position + 1; + continue; + } + } + ranges.push((position, position + 1)); + } + ranges +} + +fn compact(text: &str) -> String { + text.chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +struct Ranked<'c> { + candidate: &'c MatchCandidate, + score: i32, + field: Option, + ranges: Vec, + matched_text: String, + input_index: usize, +} + +/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing +/// about sessions, panes or the network — see docs/COMMAND-BAR.md. +pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec> { + let normalized_query = normalize_query(query); + + if normalized_query.is_empty() { + return candidates + .iter() + .map(|candidate| MatchResult { + candidate, + score: 0, + field: None, + ranges: Vec::new(), + }) + .collect(); + } + + let mut results: Vec> = Vec::new(); + + for (input_index, candidate) in candidates.iter().enumerate() { + let mut best: Option<(MatchFieldName, &str, i32, Vec)> = None; + + for field in candidate.fields() { + let text_chars: Vec = field.text.chars().collect(); + let Some(embedding) = best_embedding(&normalized_query, &text_chars) else { + continue; + }; + + let compact_text = compact(field.text); + let normalized_query_str: String = normalized_query.iter().collect(); + let adjustment = if compact_text == normalized_query_str { + 64 + } else if compact_text.starts_with(&normalized_query_str) { + 32 + } else { + 0 + }; + let score = embedding.score + adjustment + field.weight; + + let is_better = match &best { + None => true, + Some((_, _, best_score, _)) => score > *best_score, + }; + if is_better { + best = Some((field.name, field.text, score, embedding.positions)); + } + } + + if let Some((field_name, field_text, score, positions)) = best { + results.push(Ranked { + candidate, + score, + field: Some(field_name), + ranges: to_ranges(&positions), + matched_text: field_text.to_string(), + input_index, + }); + } + } + + results.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind()))) + .then_with(|| { + a.matched_text + .chars() + .count() + .cmp(&b.matched_text.chars().count()) + }) + .then_with(|| a.matched_text.cmp(&b.matched_text)) + .then_with(|| a.input_index.cmp(&b.input_index)) + }); + + results + .into_iter() + .map(|ranked| MatchResult { + candidate: ranked.candidate, + score: ranked.score, + field: ranked.field, + ranges: ranked.ranges, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session( + name: &str, + directory: Option<&str>, + command: Option<&str>, + tool: Option<&str>, + ) -> MatchCandidate { + MatchCandidate::Session(SessionCandidate { + name: name.to_string(), + directory: directory.map(str::to_string), + command: command.map(str::to_string), + tool: tool.map(str::to_string), + }) + } + + fn command(label: &str) -> MatchCandidate { + MatchCandidate::Command(CommandCandidate { + label: label.to_string(), + }) + } + + fn directory(path: &str) -> MatchCandidate { + MatchCandidate::Directory(DirectoryCandidate { + path: path.to_string(), + }) + } + + fn label(candidate: &MatchCandidate) -> String { + match candidate { + MatchCandidate::Session(s) => format!("session:{}", s.name), + MatchCandidate::Command(c) => format!("command:{}", c.label), + MatchCandidate::Directory(d) => format!("directory:{}", d.path), + } + } + + fn positions(result: &MatchResult) -> Vec { + result + .ranges + .iter() + .flat_map(|&(start, end)| start..end) + .collect() + } + + #[allow(clippy::type_complexity)] + fn expect_results( + query: &str, + candidates: &[MatchCandidate], + expected: &[(&str, i32, Option, &[usize])], + ) { + let results = match_and_rank(query, candidates); + + let actual: Vec<(String, i32)> = results + .iter() + .map(|r| (label(r.candidate), r.score)) + .collect(); + let expected_labels: Vec<(String, i32)> = expected + .iter() + .map(|(l, s, _, _)| (l.to_string(), *s)) + .collect(); + assert_eq!(actual, expected_labels, "order/scores for query {query:?}"); + + let actual_fields: Vec> = results.iter().map(|r| r.field).collect(); + let expected_fields: Vec> = + expected.iter().map(|(_, _, f, _)| *f).collect(); + assert_eq!(actual_fields, expected_fields, "fields for query {query:?}"); + + let actual_positions: Vec> = results.iter().map(positions).collect(); + let expected_positions: Vec> = + expected.iter().map(|(_, _, _, p)| p.to_vec()).collect(); + assert_eq!( + actual_positions, expected_positions, + "positions for query {query:?}" + ); + } + + #[test] + fn f1_empty_query_keeps_input_order() { + expect_results( + "", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ("session:kill", 0, None, &[]), + ("session:killer-app", 0, None, &[]), + ("command:Kill selected session", 0, None, &[]), + ], + ); + } + + #[test] + fn f2_exact_session_name_beats_prefix_kind_breaks_tie() { + expect_results( + "kill", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ( + "session:kill", + 178, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "session:killer-app", + 146, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "command:Kill selected session", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } + + #[test] + fn f3_path_segment_boundaries_beat_mid_segment() { + expect_results( + "foo", + &[ + directory("~/dev/foobar"), + directory("~/src/myfoo"), + directory("~/foo"), + ], + &[ + ( + "directory:~/foo", + 78, + Some(MatchFieldName::Path), + &[2, 3, 4], + ), + ( + "directory:~/dev/foobar", + 74, + Some(MatchFieldName::Path), + &[6, 7, 8], + ), + ( + "directory:~/src/myfoo", + 56, + Some(MatchFieldName::Path), + &[8, 9, 10], + ), + ], + ); + } + + #[test] + fn f4_no_match_returns_empty() { + expect_results( + "zzz", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + command("Kill selected session"), + ], + &[], + ); + } + + #[test] + fn f5_highest_scoring_embedding_beats_greedy() { + expect_results( + "mtu", + &[ + session("maestro-tui", None, None, None), + session("maestro-daemon", None, None, None), + session("mtui-scratch", None, None, None), + ], + &[ + ( + "session:mtui-scratch", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:maestro-tui", + 91, + Some(MatchFieldName::Name), + &[0, 8, 9], + ), + ], + ); + } + + #[test] + fn f6_name_beats_weighted_directory() { + expect_results( + "api", + &[ + session("api", Some("~/dev/web"), Some("bash"), None), + session("worker", Some("~/dev/api"), Some("bash"), None), + ], + &[ + ("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]), + ( + "session:worker", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ], + ); + } + + #[test] + fn f7_case_insensitive_length_breaks_tie() { + expect_results( + "api", + &[ + session("API-Gateway", None, None, None), + session("apiary", None, None, None), + ], + &[ + ( + "session:apiary", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:API-Gateway", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ], + ); + } + + #[test] + fn f8_exact_tool_beats_command_field() { + expect_results( + "claude", + &[ + session( + "nameless", + Some("~/dev/x"), + Some("claude --resume"), + Some("claude"), + ), + session("clay", Some("~/dev/y"), Some("bash"), None), + ], + &[( + "session:nameless", + 186, + Some(MatchFieldName::Tool), + &[0, 1, 2, 3, 4, 5], + )], + ); + } + + #[test] + fn f9_separator_earns_word_boundary_bonus() { + expect_results( + "ws", + &[ + session("websearch", None, None, None), + session("web-search", None, None, None), + ], + &[ + ( + "session:web-search", + 71, + Some(MatchFieldName::Name), + &[0, 4], + ), + ("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]), + ], + ); + } + + #[test] + fn f10_command_labels_rank_by_boundary_and_gap() { + expect_results( + "se", + &[ + command("Kill selected session"), + command("Create a session"), + command("Show help"), + ], + &[ + ( + "command:Show help", + 53, + Some(MatchFieldName::Label), + &[0, 6], + ), + ( + "command:Kill selected session", + 51, + Some(MatchFieldName::Label), + &[5, 6], + ), + ( + "command:Create a session", + 47, + Some(MatchFieldName::Label), + &[9, 10], + ), + ], + ); + } + + #[test] + fn f11_directory_matches_separate_on_initial_gap() { + expect_results( + "dev", + &[ + session("api", Some("~/dev/api"), Some("bash"), None), + session("notes", Some("~/documents/dev-notes"), Some("nvim"), None), + session("scratch", Some("~/srv/devops"), Some("bash"), None), + ], + &[ + ( + "session:api", + 54, + Some(MatchFieldName::Directory), + &[2, 3, 4], + ), + ( + "session:scratch", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ( + "session:notes", + 44, + Some(MatchFieldName::Directory), + &[12, 13, 14], + ), + ], + ); + } + + #[test] + fn f12_exact_beats_prefix() { + expect_results( + "quit", + &[command("Quit Maestro"), command("Quit")], + &[ + ( + "command:Quit", + 178, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ( + "command:Quit Maestro", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } +}