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}