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}