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