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}