Skip to main content

zero_tui/app/
picker.rs

1//! Slash-command picker — live-filtered list of commands the
2//! operator can tab-complete into the prompt.
3//!
4//! # Activation
5//!
6//! The picker is **ambient**: every render, `app::state` checks
7//! whether the first row of the prompt buffer starts with `/`,
8//! and if so, builds a fresh [`SlashPicker`] from the filter
9//! string (the characters after the `/`). There is no "open /
10//! close" flag — typing the leading slash opens it, deleting it
11//! closes it. This matches the "no mode" invariant used by the
12//! overlay system and avoids a stale picker hanging around when
13//! the operator clears the prompt.
14//!
15//! The picker yields priority to the friction-pause overlay:
16//! when a gate is active, `app::state` suppresses the picker
17//! regardless of prompt contents.
18//!
19//! # Matching
20//!
21//! The match function is a deliberately simple subsequence
22//! scorer (fzf-lite). Each candidate name is walked character
23//! by character; a match requires the filter's chars to appear
24//! in order. Score is built from:
25//!
26//! * `-distance_to_prefix` — matches that start at character 0
27//!   rank higher than mid-name matches (`/h` prefers `/help`
28//!   over `/flat`ten-all with `h` mid-word).
29//! * `-cluster_penalty` — contiguous runs are cheaper than
30//!   scattered hits (so `/sta` prefers `/status` over `/state`
31//!   only because the whole query is contiguous in both; the
32//!   tiebreaker falls back to catalog order).
33//!
34//! No external crate. fzf-rs and nucleo-matcher are both good
35//! libraries, but both are 5-figure-LOC deps and we need exactly
36//! 14 entries to match — a subsequence scorer is sufficient and
37//! zero-dep friendly.
38
39use zero_commands::{COMMAND_CATALOG, CommandInfo};
40
41/// Max visible rows in the picker popup. Six fits the common
42/// case (all current commands after a two-char filter) without
43/// stealing the conversation pane.
44pub const PICKER_MAX_VISIBLE: usize = 6;
45
46#[derive(Debug)]
47pub struct SlashPicker {
48    /// Filtered + scored entries, highest-score first.
49    matches: Vec<SlashMatch>,
50    /// Zero-based index into [`SlashPicker::matches`] of the
51    /// currently highlighted row. `0` when there are no matches.
52    selected: usize,
53}
54
55/// One picker row — the catalog entry plus bookkeeping used by
56/// the widget to bold the matched chars.
57#[derive(Debug, Clone)]
58pub struct SlashMatch {
59    pub info: CommandInfo,
60    /// Char indices (within `info.name`) that matched the filter.
61    /// Empty when the filter is empty (everything matches).
62    pub matched_chars: Vec<usize>,
63}
64
65impl SlashPicker {
66    /// Build a picker for a full prompt line (first row only).
67    /// Returns `None` when the line does not start with `/`.
68    ///
69    /// The filter is the text after the leading slash, truncated
70    /// at the first whitespace: `/pos BTC` filters on `pos`.
71    #[must_use]
72    pub fn from_prompt_line(first_line: &str) -> Option<Self> {
73        let rest = first_line.strip_prefix('/')?;
74        let filter: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
75        Some(Self::filter_catalog(&filter))
76    }
77
78    fn filter_catalog(filter: &str) -> Self {
79        let needle = filter.to_ascii_lowercase();
80        let mut scored: Vec<(i64, SlashMatch)> = Vec::new();
81        for info in COMMAND_CATALOG {
82            // Strip the leading `/` on the candidate so the filter
83            // `"h"` matches `help` at position 0, not position 1.
84            let candidate = info.name.strip_prefix('/').unwrap_or(info.name);
85            if let Some((score, matched_chars)) = fuzzy_score(&needle, candidate) {
86                // Shift matched indices by +1 so callers can use
87                // them against `info.name` (which still has `/`).
88                let shifted = matched_chars.iter().map(|i| i + 1).collect();
89                scored.push((
90                    score,
91                    SlashMatch {
92                        info: *info,
93                        matched_chars: shifted,
94                    },
95                ));
96            }
97        }
98        // Descending score, then catalog order preserved for ties.
99        scored.sort_by(|a, b| b.0.cmp(&a.0));
100        let matches: Vec<SlashMatch> = scored.into_iter().map(|(_, m)| m).collect();
101        Self {
102            matches,
103            selected: 0,
104        }
105    }
106
107    /// Picker is *active* when there is at least one match to
108    /// show. An inactive picker renders nothing.
109    #[must_use]
110    pub fn is_active(&self) -> bool {
111        !self.matches.is_empty()
112    }
113
114    #[must_use]
115    pub fn matches(&self) -> &[SlashMatch] {
116        &self.matches
117    }
118
119    #[must_use]
120    pub const fn selected_index(&self) -> usize {
121        self.selected
122    }
123
124    /// Currently highlighted entry, or `None` when inactive.
125    #[must_use]
126    pub fn selected(&self) -> Option<&SlashMatch> {
127        self.matches.get(self.selected)
128    }
129
130    /// Move selection down (wraps to top at the bottom).
131    pub fn select_next(&mut self) {
132        if self.matches.is_empty() {
133            return;
134        }
135        self.selected = (self.selected + 1) % self.matches.len();
136    }
137
138    /// Move selection up (wraps to bottom at the top).
139    pub fn select_prev(&mut self) {
140        if self.matches.is_empty() {
141            return;
142        }
143        self.selected = if self.selected == 0 {
144            self.matches.len() - 1
145        } else {
146            self.selected - 1
147        };
148    }
149
150    /// After Tab-complete, the caller replaces the prompt with
151    /// this literal. A trailing space is appended so the operator
152    /// can immediately type arguments (e.g. `/regime BTC`).
153    #[must_use]
154    pub fn completion_text(&self) -> Option<String> {
155        self.selected().map(|m| format!("{} ", m.info.name))
156    }
157}
158
159/// Subsequence scorer. Returns `None` if `needle` cannot be
160/// matched as a subsequence of `haystack`; otherwise returns
161/// `(score, matched_char_positions)`. Higher score is better.
162///
163/// Scores are computed entirely in `i64` so picker sorting stays
164/// deterministic on 32-bit and 64-bit targets without casting
165/// through `usize → i32` (which clippy rightly flags as
166/// wrap-prone).
167fn fuzzy_score(needle: &str, haystack: &str) -> Option<(i64, Vec<usize>)> {
168    if needle.is_empty() {
169        return Some((0, Vec::new()));
170    }
171    let hay: Vec<char> = haystack.chars().map(|c| c.to_ascii_lowercase()).collect();
172    let pat: Vec<char> = needle.chars().collect();
173    let mut matched: Vec<usize> = Vec::with_capacity(pat.len());
174    let mut hi = 0usize;
175    for &p in &pat {
176        let mut found = None;
177        while hi < hay.len() {
178            if hay[hi] == p {
179                found = Some(hi);
180                hi += 1;
181                break;
182            }
183            hi += 1;
184        }
185        matched.push(found?);
186    }
187
188    // Score: prefix match is best (reward `first_index == 0`),
189    // then reward contiguity (each adjacent pair saves a gap
190    // penalty). All arithmetic is i64 to avoid platform-dependent
191    // casts.
192    let first = i64::try_from(matched[0]).unwrap_or(i64::MAX);
193    let contiguous: i64 = matched
194        .windows(2)
195        .filter(|pair| pair[1] == pair[0] + 1)
196        .count()
197        .try_into()
198        .unwrap_or(i64::MAX);
199    // Exact-prefix bonus. `/h` on `help` beats `/h` on `flatten`.
200    let prefix_bonus: i64 = if first == 0 { 50 } else { 0 };
201    let score = prefix_bonus + contiguous * 10 - first;
202    Some((score, matched))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn no_slash_means_no_picker() {
211        assert!(SlashPicker::from_prompt_line("hello").is_none());
212        assert!(SlashPicker::from_prompt_line("").is_none());
213    }
214
215    #[test]
216    fn empty_filter_lists_every_entry_in_catalog_order() {
217        let p = SlashPicker::from_prompt_line("/").expect("picker");
218        assert_eq!(p.matches().len(), COMMAND_CATALOG.len());
219        // With empty filter, scoring is flat (0); sort is stable,
220        // so catalog order is preserved.
221        for (i, m) in p.matches().iter().enumerate() {
222            assert_eq!(m.info.name, COMMAND_CATALOG[i].name);
223        }
224    }
225
226    #[test]
227    fn filter_narrows_and_orders_by_prefix_match() {
228        let p = SlashPicker::from_prompt_line("/st").expect("picker");
229        // Expected matches: /status, /state. Both prefix-match,
230        // but /state and /status both begin with "st" — tie
231        // broken by catalog order (status listed before state).
232        let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
233        assert!(names.contains(&"/status"), "want /status in {names:?}");
234        assert!(names.contains(&"/state"), "want /state in {names:?}");
235        assert_eq!(names[0], "/status", "catalog ordering should be preserved");
236    }
237
238    #[test]
239    fn fuzzy_subsequence_match() {
240        // "pe" matches /pause-entries (subsequence p..e) and
241        // /pos is excluded because there is no `e`.
242        let p = SlashPicker::from_prompt_line("/pe").expect("picker");
243        let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
244        assert!(names.contains(&"/pause-entries"));
245        assert!(!names.contains(&"/pos"));
246    }
247
248    #[test]
249    fn selection_wraps_in_both_directions() {
250        let mut p = SlashPicker::from_prompt_line("/st").expect("picker");
251        let len = p.matches().len();
252        assert!(len >= 2);
253        let orig = p.selected_index();
254        for _ in 0..len {
255            p.select_next();
256        }
257        assert_eq!(p.selected_index(), orig, "next wraps at len");
258        p.select_prev();
259        assert_eq!(p.selected_index(), (orig + len - 1) % len);
260    }
261
262    #[test]
263    fn completion_text_appends_trailing_space() {
264        let p = SlashPicker::from_prompt_line("/he").expect("picker");
265        let comp = p.completion_text().expect("selected");
266        assert!(comp.ends_with(' '));
267        assert!(comp.trim_end().starts_with('/'));
268    }
269
270    #[test]
271    fn filter_stops_at_first_space() {
272        // The operator typed `/regime BTC` — filter is `regime`.
273        let p = SlashPicker::from_prompt_line("/regime BTC").expect("picker");
274        let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
275        assert_eq!(names[0], "/regime");
276    }
277
278    #[test]
279    fn matched_char_indices_point_into_name() {
280        let p = SlashPicker::from_prompt_line("/he").expect("picker");
281        let first = &p.matches()[0];
282        // Indices are into `info.name`, which includes the `/`.
283        for &i in &first.matched_chars {
284            assert!(
285                i > 0 && i < first.info.name.chars().count(),
286                "index {i} out of bounds for {}",
287                first.info.name
288            );
289        }
290    }
291}