steer_tui/tui/handlers/
fuzzy_finder.rs

1use crate::error::Result;
2use crate::tui::InputMode;
3use crate::tui::Tui;
4use crate::tui::theme::ThemeLoader;
5use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode;
6use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use tui_textarea::Input;
8
9impl Tui {
10    pub async fn handle_fuzzy_finder_mode(&mut self, key: KeyEvent) -> Result<bool> {
11        use crate::tui::widgets::fuzzy_finder::FuzzyFinderResult;
12
13        // Handle various newline key combinations
14        if (key.code == KeyCode::Enter
15            && (key.modifiers == KeyModifiers::SHIFT
16                || key.modifiers == KeyModifiers::ALT
17                || key.modifiers == KeyModifiers::CONTROL))
18            || (key.code == KeyCode::Char('j') && key.modifiers == KeyModifiers::CONTROL)
19        {
20            self.input_panel_state
21                .handle_input(Input::from(KeyEvent::new(
22                    KeyCode::Char('\n'),
23                    KeyModifiers::empty(),
24                )));
25            return Ok(false);
26        }
27
28        // Get the current mode
29        let mode = self.input_panel_state.fuzzy_finder.mode();
30
31        // First, let the input panel process the key
32        let post_result = self.input_panel_state.handle_fuzzy_key(key).await;
33
34        // Determine if cursor is still immediately after trigger character
35        let cursor_after_trigger = {
36            match mode {
37                FuzzyFinderMode::Models | FuzzyFinderMode::Themes => {
38                    // For models and themes, always stay active until explicitly closed
39                    true
40                }
41                FuzzyFinderMode::Files | FuzzyFinderMode::Commands => {
42                    let content = self.input_panel_state.content();
43                    let (row, col) = self.input_panel_state.textarea.cursor();
44                    // Get absolute byte offset of cursor by summing line lengths + newlines
45                    let mut offset = 0usize;
46                    for (i, line) in self.input_panel_state.textarea.lines().iter().enumerate() {
47                        if i == row {
48                            offset += col;
49                            break;
50                        } else {
51                            offset += line.len() + 1;
52                        }
53                    }
54                    // Check if we have a stored trigger position
55                    if let Some(trigger_pos) =
56                        self.input_panel_state.fuzzy_finder.trigger_position()
57                    {
58                        // Check if cursor is past the trigger and no whitespace between
59                        if offset <= trigger_pos {
60                            false // Cursor before the trigger
61                        } else {
62                            let bytes = content.as_bytes();
63                            // Check for whitespace between trigger and cursor
64                            let mut still_in_word = true;
65                            for idx in trigger_pos + 1..offset {
66                                if idx >= bytes.len() {
67                                    break;
68                                }
69                                match bytes[idx] {
70                                    b' ' | b'\t' | b'\n' => {
71                                        still_in_word = false;
72                                        break;
73                                    }
74                                    _ => {}
75                                }
76                            }
77                            still_in_word
78                        }
79                    } else {
80                        false // No trigger position stored
81                    }
82                }
83            }
84        };
85
86        if !cursor_after_trigger {
87            // The command fuzzy finder closed because we typed whitespace.
88            // If the user just finished typing a top-level command like "/model " or
89            // "/theme ", immediately open the next-level fuzzy finder.
90            let reopen_handled = if mode == FuzzyFinderMode::Commands
91                && key.code == KeyCode::Char(' ')
92                && key.modifiers == KeyModifiers::NONE
93            {
94                let content = self.input_panel_state.content();
95                let cursor_pos = self.input_panel_state.get_cursor_byte_offset();
96                if cursor_pos > 0 {
97                    use crate::tui::commands::{CoreCommandType, TuiCommandType};
98                    let before_space = &content[..cursor_pos - 1]; // exclude the space itself
99                    let model_cmd = format!("/{}", CoreCommandType::Model.command_name());
100                    let theme_cmd = format!("/{}", TuiCommandType::Theme.command_name());
101                    let is_model_cmd = before_space.trim_end().ends_with(&model_cmd);
102                    let is_theme_cmd = before_space.trim_end().ends_with(&theme_cmd);
103                    if is_model_cmd || is_theme_cmd {
104                        // Don't clear the textarea - keep the command visible
105                        // The fuzzy finder will overlay on top of the existing text
106
107                        use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode as FMode;
108                        if is_model_cmd {
109                            self.input_panel_state
110                                .fuzzy_finder
111                                .activate(cursor_pos, FMode::Models);
112                            // Populate models
113                            use steer_core::api::Model;
114
115                            let current_model = self.current_model;
116                            let models: Vec<_> = Model::iter_recommended()
117                                .map(|m| {
118                                    let n = m.as_ref();
119                                    if m == current_model {
120                                        crate::tui::widgets::fuzzy_finder::PickerItem::simple(
121                                            format!("{n} (current)"),
122                                        )
123                                    } else {
124                                        crate::tui::widgets::fuzzy_finder::PickerItem::simple(
125                                            n.to_string(),
126                                        )
127                                    }
128                                })
129                                .collect();
130                            self.input_panel_state.fuzzy_finder.update_results(models);
131                        } else {
132                            self.input_panel_state
133                                .fuzzy_finder
134                                .activate(cursor_pos, FMode::Themes);
135                            // Populate themes
136                            let loader = ThemeLoader::new();
137                            let themes: Vec<_> = loader
138                                .list_themes()
139                                .into_iter()
140                                .map(crate::tui::widgets::fuzzy_finder::PickerItem::simple)
141                                .collect();
142                            self.input_panel_state.fuzzy_finder.update_results(themes);
143                        }
144                        self.switch_mode(InputMode::FuzzyFinder);
145                        true
146                    } else {
147                        false
148                    }
149                } else {
150                    false
151                }
152            } else {
153                false
154            };
155
156            if !reopen_handled {
157                self.input_panel_state.deactivate_fuzzy();
158                self.restore_previous_mode();
159            }
160            return Ok(false);
161        }
162
163        // Otherwise handle explicit results (Enter / Esc etc.)
164        if let Some(result) = post_result {
165            match result {
166                FuzzyFinderResult::Close => {
167                    self.input_panel_state.deactivate_fuzzy();
168                    self.restore_previous_mode();
169                }
170                FuzzyFinderResult::Select(selected_item) => {
171                    match mode {
172                        FuzzyFinderMode::Files => {
173                            // Complete with file path using the insert text
174                            self.input_panel_state.complete_picker_item(&selected_item);
175                        }
176                        FuzzyFinderMode::Commands => {
177                            // Extract just the command name from the label
178                            let selected_cmd = selected_item.label.as_str();
179                            // Check if this is model or theme command
180                            use crate::tui::commands::{CoreCommandType, TuiCommandType};
181                            let model_cmd_name = CoreCommandType::Model.command_name();
182                            let theme_cmd_name = TuiCommandType::Theme.command_name();
183
184                            if selected_cmd == model_cmd_name || selected_cmd == theme_cmd_name {
185                                // User selected model or theme - open the appropriate fuzzy finder
186                                let content = format!("/{selected_cmd} ");
187                                self.input_panel_state.clear();
188                                self.input_panel_state
189                                    .set_content_from_lines(vec![&content]);
190
191                                // Set cursor at end
192                                let cursor_pos = content.len();
193                                self.input_panel_state
194                                    .textarea
195                                    .move_cursor(tui_textarea::CursorMove::End);
196
197                                use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode as FMode;
198                                if selected_cmd == model_cmd_name {
199                                    self.input_panel_state
200                                        .fuzzy_finder
201                                        .activate(cursor_pos, FMode::Models);
202
203                                    // Populate models
204                                    use steer_core::api::Model;
205
206                                    let current_model = self.current_model;
207                                    let models: Vec<_> = Model::iter_recommended()
208                                        .map(|m| {
209                                            let model_str = m.as_ref();
210                                            if m == current_model {
211                                                crate::tui::widgets::fuzzy_finder::PickerItem::simple(
212                                                    format!("{model_str} (current)")
213                                                )
214                                            } else {
215                                                crate::tui::widgets::fuzzy_finder::PickerItem::simple(
216                                                    model_str.to_string()
217                                                )
218                                            }
219                                        })
220                                        .collect();
221                                    self.input_panel_state.fuzzy_finder.update_results(models);
222                                } else {
223                                    self.input_panel_state
224                                        .fuzzy_finder
225                                        .activate(cursor_pos, FMode::Themes);
226
227                                    // Populate themes
228                                    let loader = ThemeLoader::new();
229                                    let themes: Vec<_> = loader
230                                        .list_themes()
231                                        .into_iter()
232                                        .map(crate::tui::widgets::fuzzy_finder::PickerItem::simple)
233                                        .collect();
234                                    self.input_panel_state.fuzzy_finder.update_results(themes);
235                                }
236                                // Stay in fuzzy finder mode
237                                self.input_mode = InputMode::FuzzyFinder;
238                            } else {
239                                // Complete with command using the insert text
240                                self.input_panel_state.complete_picker_item(&selected_item);
241                                self.input_panel_state.deactivate_fuzzy();
242                                self.restore_previous_mode();
243                            }
244                        }
245                        FuzzyFinderMode::Models => {
246                            // Extract model name (remove " (current)" suffix if present)
247                            let model_name = selected_item.label.trim_end_matches(" (current)");
248                            // Send the model command using command_name()
249                            use crate::tui::commands::CoreCommandType;
250                            let command = format!(
251                                "/{} {}",
252                                CoreCommandType::Model.command_name(),
253                                model_name
254                            );
255                            self.send_message(command).await?;
256                            // Clear the input after sending
257                            self.input_panel_state.clear();
258                        }
259                        FuzzyFinderMode::Themes => {
260                            // Send the theme command using command_name()
261                            use crate::tui::commands::TuiCommandType;
262                            let command = format!(
263                                "/{} {}",
264                                TuiCommandType::Theme.command_name(),
265                                selected_item.label
266                            );
267                            self.send_message(command).await?;
268                            // Clear the input after sending
269                            self.input_panel_state.clear();
270                        }
271                    }
272                    if mode != FuzzyFinderMode::Commands {
273                        self.input_panel_state.deactivate_fuzzy();
274                        self.restore_previous_mode();
275                    }
276                }
277            }
278        }
279
280        // Handle typing for command search
281        if mode == FuzzyFinderMode::Commands {
282            // Extract search query from content
283            let content = self.input_panel_state.content();
284            if let Some(trigger_pos) = self.input_panel_state.fuzzy_finder.trigger_position() {
285                if trigger_pos + 1 < content.len() {
286                    let query = &content[trigger_pos + 1..];
287                    // Search commands
288                    let results: Vec<_> = self
289                        .command_registry
290                        .search(query)
291                        .into_iter()
292                        .map(|cmd| {
293                            crate::tui::widgets::fuzzy_finder::PickerItem::new(
294                                cmd.name.to_string(),
295                                format!("/{} ", cmd.name),
296                            )
297                        })
298                        .collect();
299                    self.input_panel_state.fuzzy_finder.update_results(results);
300                }
301            }
302        } else if mode == FuzzyFinderMode::Models || mode == FuzzyFinderMode::Themes {
303            // For models and themes, use the typed content *after the command prefix* as search query
304            use crate::tui::commands::{CoreCommandType, TuiCommandType};
305            let raw_content = self.input_panel_state.content();
306            let query = match mode {
307                FuzzyFinderMode::Models => {
308                    let prefix = format!("/{} ", CoreCommandType::Model.command_name());
309                    raw_content
310                        .strip_prefix(&prefix)
311                        .unwrap_or(&raw_content)
312                        .to_string()
313                }
314                FuzzyFinderMode::Themes => {
315                    let prefix = format!("/{} ", TuiCommandType::Theme.command_name());
316                    raw_content
317                        .strip_prefix(&prefix)
318                        .unwrap_or(&raw_content)
319                        .to_string()
320                }
321                _ => unreachable!(),
322            };
323
324            match mode {
325                FuzzyFinderMode::Models => {
326                    // Filter models based on query
327                    use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
328                    use steer_core::api::Model;
329
330                    let matcher = SkimMatcherV2::default();
331                    let current_model = self.current_model;
332
333                    let mut scored_models: Vec<(i64, String)> = Model::iter_recommended()
334                        .filter_map(|m| {
335                            let model_str = m.as_ref();
336                            let display_str = if m == current_model {
337                                format!("{model_str} (current)")
338                            } else {
339                                model_str.to_string()
340                            };
341
342                            let display_score = matcher.fuzzy_match(&display_str, &query);
343                            let exact_alias_match = m
344                                .aliases()
345                                .iter()
346                                .any(|alias| alias.eq_ignore_ascii_case(&query));
347
348                            let alias_score = if exact_alias_match {
349                                Some(1000) // High score for exact matches
350                            } else {
351                                m.aliases()
352                                    .iter()
353                                    .filter_map(|alias| matcher.fuzzy_match(alias, &query))
354                                    .max()
355                            };
356
357                            // Take the maximum score
358                            let best_score = match (display_score, alias_score) {
359                                (Some(d), Some(a)) => Some(d.max(a)),
360                                (Some(d), None) => Some(d),
361                                (None, Some(a)) => Some(a),
362                                (None, None) => None,
363                            };
364
365                            best_score.map(|score| (score, display_str))
366                        })
367                        .collect();
368
369                    // Sort by score (highest first)
370                    scored_models.sort_by(|a, b| b.0.cmp(&a.0));
371
372                    let results: Vec<_> = scored_models
373                        .into_iter()
374                        .map(|(_, model)| {
375                            crate::tui::widgets::fuzzy_finder::PickerItem::simple(model)
376                        })
377                        .collect();
378
379                    self.input_panel_state.fuzzy_finder.update_results(results);
380                }
381                FuzzyFinderMode::Themes => {
382                    // Filter themes based on query
383                    use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
384
385                    let loader = ThemeLoader::new();
386                    let all_themes = loader.list_themes();
387
388                    if query.is_empty() {
389                        let picker_items: Vec<_> = all_themes
390                            .into_iter()
391                            .map(crate::tui::widgets::fuzzy_finder::PickerItem::simple)
392                            .collect();
393                        self.input_panel_state
394                            .fuzzy_finder
395                            .update_results(picker_items);
396                    } else {
397                        let matcher = SkimMatcherV2::default();
398                        let mut scored_themes: Vec<(i64, String)> = all_themes
399                            .into_iter()
400                            .filter_map(|theme| {
401                                matcher
402                                    .fuzzy_match(&theme, &query)
403                                    .map(|score| (score, theme))
404                            })
405                            .collect();
406
407                        // Sort by score (highest first)
408                        scored_themes.sort_by(|a, b| b.0.cmp(&a.0));
409
410                        let results: Vec<_> = scored_themes
411                            .into_iter()
412                            .map(|(_, theme)| {
413                                crate::tui::widgets::fuzzy_finder::PickerItem::simple(theme)
414                            })
415                            .collect();
416
417                        self.input_panel_state.fuzzy_finder.update_results(results);
418                    }
419                }
420                _ => {}
421            }
422        }
423
424        Ok(false)
425    }
426}