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}