Skip to main content

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