intelli_shell/component/
search.rs

1use std::{
2    sync::{Arc, Mutex},
3    time::Duration,
4};
5
6use async_trait::async_trait;
7use color_eyre::Result;
8use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
9use enum_cycling::EnumCycle;
10use parking_lot::RwLock;
11use ratatui::{
12    Frame,
13    layout::{Constraint, Layout, Rect},
14};
15use tokio_util::sync::CancellationToken;
16use tracing::instrument;
17use tui_textarea::CursorMove;
18
19use super::Component;
20use crate::{
21    app::Action,
22    component::{
23        edit::{EditCommandComponent, EditCommandComponentMode},
24        variable::VariableReplacementComponent,
25    },
26    config::{Config, KeyBindingsConfig, SearchConfig, Theme},
27    errors::AppError,
28    format_msg,
29    model::{Command, CommandTemplate, SOURCE_WORKSPACE, SearchMode},
30    process::ProcessOutput,
31    service::IntelliShellService,
32    widgets::{CustomList, CustomTextArea, ErrorPopup, NewVersionBanner, items::string::CommentString},
33};
34
35const EMPTY_STORAGE_MESSAGE: &str = r#"There are no stored commands yet!
36    - Try to bookmark some command with 'Ctrl + B'
37    - Or execute 'intelli-shell tldr fetch' to download a bunch of tldr's useful commands"#;
38
39/// A component for searching [`Command`]
40#[derive(Clone)]
41pub struct SearchCommandsComponent {
42    /// The visual theme for styling the component
43    theme: Theme,
44    /// Whether the TUI is in inline mode or not
45    inline: bool,
46    /// Whether to directly execute the command if it matches an alias exactly
47    exec_on_alias_match: bool,
48    /// Service for interacting with command storage
49    service: IntelliShellService,
50    /// The component layout
51    layout: Layout,
52    /// The delay before triggering a search after user input
53    search_delay: Duration,
54    /// Cancellation token for the current refresh task
55    refresh_token: Arc<Mutex<Option<CancellationToken>>>,
56    /// The state of the component
57    state: Arc<RwLock<SearchCommandsComponentState<'static>>>,
58}
59struct SearchCommandsComponentState<'a> {
60    /// The next component initialization must prompt AI
61    initialize_with_ai: bool,
62    /// The default search mode
63    mode: SearchMode,
64    /// Whether to search for user commands only by default (excluding tldr)
65    user_only: bool,
66    /// The active query
67    query: CustomTextArea<'a>,
68    /// Whether ai mode is currently enabled
69    ai_mode: bool,
70    /// List of tags, if currently editing a tag
71    tags: Option<CustomList<'a, CommentString>>,
72    /// Whether the command search was an alias match
73    alias_match: bool,
74    /// The list of commands
75    commands: CustomList<'a, Command>,
76    /// Popup for displaying error messages
77    error: ErrorPopup<'a>,
78}
79
80impl SearchCommandsComponent {
81    /// Creates a new [`SearchCommandsComponent`]
82    pub fn new(
83        service: IntelliShellService,
84        config: Config,
85        inline: bool,
86        query: impl Into<String>,
87        initialize_with_ai: bool,
88    ) -> Self {
89        let query = CustomTextArea::new(config.theme.primary, inline, false, query.into()).focused();
90
91        let commands = CustomList::new(config.theme.clone(), inline, Vec::new());
92
93        let error = ErrorPopup::empty(&config.theme);
94
95        let layout = if inline {
96            Layout::vertical([Constraint::Length(1), Constraint::Min(3)])
97        } else {
98            Layout::vertical([Constraint::Length(3), Constraint::Min(5)]).margin(1)
99        };
100
101        let SearchConfig {
102            delay,
103            mode,
104            user_only,
105            exec_on_alias_match,
106        } = config.search;
107
108        let ret = Self {
109            theme: config.theme,
110            inline,
111            exec_on_alias_match,
112            service,
113            layout,
114            search_delay: Duration::from_millis(delay),
115            refresh_token: Arc::new(Mutex::new(None)),
116            state: Arc::new(RwLock::new(SearchCommandsComponentState {
117                initialize_with_ai,
118                mode,
119                user_only,
120                query,
121                ai_mode: false,
122                tags: None,
123                alias_match: false,
124                commands,
125                error,
126            })),
127        };
128
129        ret.update_config(None, None, None);
130
131        ret
132    }
133
134    /// Updates the search config
135    fn update_config(&self, search_mode: Option<SearchMode>, user_only: Option<bool>, ai_mode: Option<bool>) {
136        let mut state = self.state.write();
137        if let Some(search_mode) = search_mode {
138            state.mode = search_mode;
139        }
140        if let Some(user_only) = user_only {
141            state.user_only = user_only;
142        }
143        if let Some(ai_mode) = ai_mode {
144            state.ai_mode = ai_mode;
145        }
146
147        let search_mode = state.mode;
148        let title = match (state.ai_mode, self.inline, state.user_only) {
149            (true, true, _) => String::from("(ai)"),
150            (false, true, true) => format!("({search_mode},user)"),
151            (false, true, false) => format!("({search_mode})"),
152            (true, false, _) => String::from(" Query (ai) "),
153            (false, false, true) => format!(" Query ({search_mode},user) "),
154            (false, false, false) => format!(" Query ({search_mode}) "),
155        };
156
157        state.query.set_title(title);
158    }
159}
160
161#[async_trait]
162impl Component for SearchCommandsComponent {
163    fn name(&self) -> &'static str {
164        "SearchCommandsComponent"
165    }
166
167    fn min_inline_height(&self) -> u16 {
168        // Query + 10 Commands
169        1 + 10
170    }
171
172    #[instrument(skip_all)]
173    async fn init_and_peek(&mut self) -> Result<Action> {
174        // Check if the component should initialize prompting the AI
175        let initialize_with_ai = self.state.read().initialize_with_ai;
176        if initialize_with_ai {
177            let res = self.prompt_ai().await;
178            self.state.write().initialize_with_ai = false;
179            return res;
180        }
181        // If the storage is empty, quit with a message
182        if self.service.is_storage_empty().await.map_err(AppError::into_report)? {
183            Ok(Action::Quit(
184                ProcessOutput::success().stderr(format_msg!(self.theme, "{EMPTY_STORAGE_MESSAGE}")),
185            ))
186        } else {
187            // Otherwise initialize the tags or commands based on the current query
188            let tags = {
189                let state = self.state.read();
190                state.query.lines_as_string() == "#"
191            };
192            if tags {
193                self.refresh_tags().await?;
194            } else {
195                self.refresh_commands().await?;
196                // And peek into the commands to check if we can give a straight answer without the TUI rendered
197                let command = {
198                    let state = self.state.read();
199                    if state.alias_match && state.commands.len() == 1 {
200                        state.commands.selected().cloned()
201                    } else {
202                        None
203                    }
204                };
205                if let Some(command) = command {
206                    tracing::info!("Found a single alias command: {}", command.cmd);
207                    return self.confirm_command(command, self.exec_on_alias_match, false).await;
208                }
209            }
210            Ok(Action::NoOp)
211        }
212    }
213
214    #[instrument(skip_all)]
215    fn render(&mut self, frame: &mut Frame, area: Rect) {
216        // Split the area according to the layout
217        let [query_area, suggestions_area] = self.layout.areas(area);
218
219        let mut state = self.state.write();
220
221        // Render the query widget
222        frame.render_widget(&state.query, query_area);
223
224        // Render the suggestions
225        if let Some(ref mut tags) = state.tags {
226            frame.render_widget(tags, suggestions_area);
227        } else {
228            frame.render_widget(&mut state.commands, suggestions_area);
229        }
230
231        // Render the new version banner and error message as an overlay
232        if let Some(new_version) = self.service.poll_new_version() {
233            NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
234        }
235        state.error.render_in(frame, area);
236    }
237
238    fn tick(&mut self) -> Result<Action> {
239        let mut state = self.state.write();
240        state.query.tick();
241        state.error.tick();
242        Ok(Action::NoOp)
243    }
244
245    fn exit(&mut self) -> Result<Action> {
246        let (ai_mode, tags) = {
247            let state = self.state.read();
248            (state.ai_mode, state.tags.is_some())
249        };
250        if ai_mode {
251            tracing::debug!("Closing ai mode: user request");
252            self.update_config(None, None, Some(false));
253            self.schedule_debounced_command_refresh();
254            Ok(Action::NoOp)
255        } else if tags {
256            tracing::debug!("Closing tag mode: user request");
257            let mut state = self.state.write();
258            state.tags = None;
259            state.commands.set_focus(true);
260            self.schedule_debounced_command_refresh();
261            Ok(Action::NoOp)
262        } else {
263            tracing::info!("User requested to exit");
264            let state = self.state.read();
265            let query = state.query.lines_as_string();
266            Ok(Action::Quit(if query.trim().is_empty() {
267                ProcessOutput::success()
268            } else {
269                ProcessOutput::success().fileout(query)
270            }))
271        }
272    }
273
274    async fn process_key_event(&mut self, keybindings: &KeyBindingsConfig, key: KeyEvent) -> Result<Action> {
275        // If `ctrl+space` was hit, attempt to refresh tags if the cursor is on it
276        if key.code == KeyCode::Char(' ') && key.modifiers == KeyModifiers::CONTROL {
277            self.debounced_refresh_tags();
278            Ok(Action::NoOp)
279        } else {
280            // Otherwise, process default actions
281            Ok(self
282                .default_process_key_event(keybindings, key)
283                .await?
284                .unwrap_or_default())
285        }
286    }
287
288    fn process_mouse_event(&mut self, mouse: MouseEvent) -> Result<Action> {
289        match mouse.kind {
290            MouseEventKind::ScrollDown => Ok(self.move_down()?),
291            MouseEventKind::ScrollUp => Ok(self.move_up()?),
292            _ => Ok(Action::NoOp),
293        }
294    }
295
296    fn move_up(&mut self) -> Result<Action> {
297        let mut state = self.state.write();
298        if !state.query.is_ai_loading() {
299            if let Some(ref mut tags) = state.tags {
300                tags.select_prev();
301            } else {
302                state.commands.select_prev();
303            }
304        }
305        Ok(Action::NoOp)
306    }
307
308    fn move_down(&mut self) -> Result<Action> {
309        let mut state = self.state.write();
310        if !state.query.is_ai_loading() {
311            if let Some(ref mut tags) = state.tags {
312                tags.select_next();
313            } else {
314                state.commands.select_next();
315            }
316        }
317        Ok(Action::NoOp)
318    }
319
320    fn move_left(&mut self, word: bool) -> Result<Action> {
321        let mut state = self.state.write();
322        if state.tags.is_none() {
323            state.query.move_cursor_left(word);
324        }
325        Ok(Action::NoOp)
326    }
327
328    fn move_right(&mut self, word: bool) -> Result<Action> {
329        let mut state = self.state.write();
330        if state.tags.is_none() {
331            state.query.move_cursor_right(word);
332        }
333        Ok(Action::NoOp)
334    }
335
336    fn move_prev(&mut self) -> Result<Action> {
337        self.move_up()
338    }
339
340    fn move_next(&mut self) -> Result<Action> {
341        self.move_down()
342    }
343
344    fn move_home(&mut self, absolute: bool) -> Result<Action> {
345        let mut state = self.state.write();
346        if !state.query.is_ai_loading() {
347            if let Some(ref mut tags) = state.tags {
348                tags.select_first();
349            } else if absolute {
350                state.commands.select_first();
351            } else {
352                state.query.move_home(false);
353            }
354        }
355        Ok(Action::NoOp)
356    }
357
358    fn move_end(&mut self, absolute: bool) -> Result<Action> {
359        let mut state = self.state.write();
360        if !state.query.is_ai_loading() {
361            if let Some(ref mut tags) = state.tags {
362                tags.select_last();
363            } else if absolute {
364                state.commands.select_last();
365            } else {
366                state.query.move_end(false);
367            }
368        }
369        Ok(Action::NoOp)
370    }
371
372    fn undo(&mut self) -> Result<Action> {
373        let mut state = self.state.write();
374        if !state.query.is_ai_loading() {
375            state.query.undo();
376            if state.tags.is_some() {
377                self.debounced_refresh_tags();
378            } else {
379                self.schedule_debounced_command_refresh();
380            }
381        }
382        Ok(Action::NoOp)
383    }
384
385    fn redo(&mut self) -> Result<Action> {
386        let mut state = self.state.write();
387        if !state.query.is_ai_loading() {
388            state.query.redo();
389            if state.tags.is_some() {
390                self.debounced_refresh_tags();
391            } else {
392                self.schedule_debounced_command_refresh();
393            }
394        }
395        Ok(Action::NoOp)
396    }
397
398    fn insert_text(&mut self, text: String) -> Result<Action> {
399        let mut state = self.state.write();
400        state.query.insert_str(text);
401        if state.tags.is_some() {
402            self.debounced_refresh_tags();
403        } else {
404            self.schedule_debounced_command_refresh();
405        }
406        Ok(Action::NoOp)
407    }
408
409    fn insert_char(&mut self, c: char) -> Result<Action> {
410        let mut state = self.state.write();
411        state.query.insert_char(c);
412        if c == '#' || state.tags.is_some() {
413            self.debounced_refresh_tags();
414        } else {
415            self.schedule_debounced_command_refresh();
416        }
417        Ok(Action::NoOp)
418    }
419
420    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
421        let mut state = self.state.write();
422        state.query.delete(backspace, word);
423        if state.tags.is_some() {
424            self.debounced_refresh_tags();
425        } else {
426            self.schedule_debounced_command_refresh();
427        }
428        Ok(Action::NoOp)
429    }
430
431    fn toggle_search_mode(&mut self) -> Result<Action> {
432        let (search_mode, ai_mode, tags) = {
433            let state = self.state.read();
434            if state.query.is_ai_loading() {
435                return Ok(Action::NoOp);
436            }
437            (state.mode, state.ai_mode, state.tags.is_some())
438        };
439        if ai_mode {
440            tracing::debug!("Closing ai mode: user toggled search mode");
441            self.update_config(None, None, Some(false));
442        } else {
443            self.update_config(Some(search_mode.down()), None, None);
444        }
445        if tags {
446            self.debounced_refresh_tags();
447        } else {
448            self.schedule_debounced_command_refresh();
449        }
450        Ok(Action::NoOp)
451    }
452
453    fn toggle_search_user_only(&mut self) -> Result<Action> {
454        let (user_only, ai_mode, tags) = {
455            let state = self.state.read();
456            (state.user_only, state.ai_mode, state.tags.is_some())
457        };
458        if !ai_mode {
459            self.update_config(None, Some(!user_only), None);
460            if tags {
461                self.debounced_refresh_tags();
462            } else {
463                self.schedule_debounced_command_refresh();
464            }
465        }
466        Ok(Action::NoOp)
467    }
468
469    #[instrument(skip_all)]
470    async fn selection_delete(&mut self) -> Result<Action> {
471        let command = {
472            let mut state = self.state.write();
473            if !state.ai_mode
474                && let Some(selected) = state.commands.selected()
475            {
476                if selected.source != SOURCE_WORKSPACE {
477                    state.commands.delete_selected()
478                } else {
479                    state.error.set_temp_message("Workspace commands can't be deleted");
480                    return Ok(Action::NoOp);
481                }
482            } else {
483                None
484            }
485        };
486
487        if let Some((_, command)) = command {
488            self.service
489                .delete_command(command.id)
490                .await
491                .map_err(AppError::into_report)?;
492        }
493
494        Ok(Action::NoOp)
495    }
496
497    #[instrument(skip_all)]
498    async fn selection_update(&mut self) -> Result<Action> {
499        let command = {
500            let state = self.state.read();
501            if state.ai_mode {
502                return Ok(Action::NoOp);
503            }
504            state.commands.selected().cloned()
505        };
506        if let Some(command) = command {
507            if command.source != SOURCE_WORKSPACE {
508                tracing::info!("Entering command update for: {}", command.cmd);
509                Ok(Action::SwitchComponent(Box::new(EditCommandComponent::new(
510                    self.service.clone(),
511                    self.theme.clone(),
512                    self.inline,
513                    command,
514                    EditCommandComponentMode::Edit {
515                        parent: Box::new(self.clone()),
516                    },
517                ))))
518            } else {
519                self.state
520                    .write()
521                    .error
522                    .set_temp_message("Workspace commands can't be updated");
523                Ok(Action::NoOp)
524            }
525        } else {
526            Ok(Action::NoOp)
527        }
528    }
529
530    #[instrument(skip_all)]
531    async fn selection_confirm(&mut self) -> Result<Action> {
532        let (selected_tag, cursor_pos, query, command, ai_mode) = {
533            let state = self.state.read();
534            if state.query.is_ai_loading() {
535                return Ok(Action::NoOp);
536            }
537            let selected_tag = state.tags.as_ref().and_then(|s| s.selected().cloned());
538            (
539                selected_tag.map(String::from),
540                state.query.cursor().1,
541                state.query.lines_as_string(),
542                state.commands.selected().cloned(),
543                state.ai_mode,
544            )
545        };
546
547        if let Some(tag) = selected_tag {
548            tracing::debug!("Selected tag: {tag}");
549            self.confirm_tag(tag, query, cursor_pos).await
550        } else if let Some(command) = command {
551            tracing::info!("Selected command: {}", command.cmd);
552            self.confirm_command(command, false, ai_mode).await
553        } else {
554            Ok(Action::NoOp)
555        }
556    }
557
558    #[instrument(skip_all)]
559    async fn selection_execute(&mut self) -> Result<Action> {
560        let (command, ai_mode) = {
561            let state = self.state.read();
562            if state.query.is_ai_loading() {
563                return Ok(Action::NoOp);
564            }
565            (state.commands.selected().cloned(), state.ai_mode)
566        };
567        if let Some(command) = command {
568            tracing::info!("Selected command to execute: {}", command.cmd);
569            self.confirm_command(command, true, ai_mode).await
570        } else {
571            Ok(Action::NoOp)
572        }
573    }
574
575    async fn prompt_ai(&mut self) -> Result<Action> {
576        let mut state = self.state.write();
577        if state.tags.is_some() || state.query.is_ai_loading() {
578            return Ok(Action::NoOp);
579        }
580        let query = state.query.lines_as_string();
581        if !query.is_empty() {
582            state.query.set_ai_loading(true);
583            drop(state);
584            self.update_config(None, None, Some(true));
585            let this = self.clone();
586            tokio::spawn(async move {
587                let res = this.service.suggest_commands(&query).await;
588                let mut state = this.state.write();
589                let commands = match res {
590                    Ok(suggestions) => {
591                        if !suggestions.is_empty() {
592                            state.error.clear_message();
593                            state.alias_match = false;
594                            suggestions
595                        } else {
596                            state
597                                .error
598                                .set_temp_message("AI did not return any suggestion".to_string());
599                            Vec::new()
600                        }
601                    }
602                    Err(AppError::UserFacing(err)) => {
603                        tracing::warn!("{err}");
604                        state.error.set_temp_message(err.to_string());
605                        Vec::new()
606                    }
607                    Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
608                };
609                state.commands.update_items(commands, true);
610                state.query.set_ai_loading(false);
611            });
612        }
613        Ok(Action::NoOp)
614    }
615}
616
617impl SearchCommandsComponent {
618    /// Schedule a debounced refresh of the commands list
619    fn schedule_debounced_command_refresh(&self) {
620        let cancellation_token = {
621            // Cancel previous token (if any)
622            let mut token_guard = self.refresh_token.lock().unwrap();
623            if let Some(token) = token_guard.take() {
624                token.cancel();
625            }
626            // Issue a new one
627            let new_token = CancellationToken::new();
628            *token_guard = Some(new_token.clone());
629            new_token
630        };
631
632        // Spawn a new task
633        let this = self.clone();
634        tokio::spawn(async move {
635            tokio::select! {
636                biased;
637                // That completes when the token is canceled
638                _ = cancellation_token.cancelled() => {}
639                // Or performs a command search after the configured delay
640                _ = tokio::time::sleep(this.search_delay) => {
641                    if let Err(err) = this.refresh_commands().await {
642                        panic!("Error refreshing commands: {err:?}");
643                    }
644                }
645            }
646        });
647    }
648
649    /// Refresh the command list
650    #[instrument(skip_all)]
651    async fn refresh_commands(&self) -> Result<()> {
652        // Retrieve the user query
653        let (mode, user_only, ai_mode, query) = {
654            let state = self.state.read();
655            (
656                state.mode,
657                state.user_only,
658                state.ai_mode,
659                state.query.lines_as_string(),
660            )
661        };
662
663        // Skip when ai mode is enabled
664        if ai_mode {
665            return Ok(());
666        }
667
668        // Search for commands
669        let res = self.service.search_commands(mode, user_only, &query).await;
670
671        // Update the command list or display an error
672        let mut state = self.state.write();
673        let commands = match res {
674            Ok((commands, alias_match)) => {
675                state.error.clear_message();
676                state.alias_match = alias_match;
677                commands
678            }
679            Err(AppError::UserFacing(err)) => {
680                tracing::warn!("{err}");
681                state.error.set_perm_message(err.to_string());
682                Vec::new()
683            }
684            Err(AppError::Unexpected(err)) => return Err(err),
685        };
686        state.commands.update_items(commands, true);
687
688        Ok(())
689    }
690
691    /// Immediately starts a debounced refresh of the tags list
692    fn debounced_refresh_tags(&self) {
693        let this = self.clone();
694        tokio::spawn(async move {
695            if let Err(err) = this.refresh_tags().await {
696                panic!("Error refreshing tags: {err:?}");
697            }
698        });
699    }
700
701    /// Refresh the suggested tags list
702    #[instrument(skip_all)]
703    async fn refresh_tags(&self) -> Result<()> {
704        // Retrieve the user query
705        let (mode, user_only, ai_mode, query, cursor_pos) = {
706            let state = self.state.read();
707            (
708                state.mode,
709                state.user_only,
710                state.ai_mode,
711                state.query.lines_as_string(),
712                state.query.cursor().1,
713            )
714        };
715
716        // Skip when ai mode is enabled
717        if ai_mode {
718            return Ok(());
719        }
720
721        // Find tags for that query
722        let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
723
724        // Update the tags list
725        let mut state = self.state.write();
726        match res {
727            Ok(None) => {
728                tracing::trace!("No editing tags");
729                if state.tags.is_some() {
730                    tracing::debug!("Closing tag mode: no editing tag");
731                    state.tags = None;
732                    state.commands.set_focus(true);
733                }
734                self.schedule_debounced_command_refresh();
735                Ok(())
736            }
737            Ok(Some(tags)) if tags.is_empty() => {
738                tracing::trace!("No tags found");
739                if state.tags.is_some() {
740                    tracing::debug!("Closing tag mode: no tags found");
741                    state.tags = None;
742                    state.commands.set_focus(true);
743                }
744                self.schedule_debounced_command_refresh();
745                Ok(())
746            }
747            Ok(Some(tags)) => {
748                state.error.clear_message();
749                if tags.len() == 1 && tags.iter().all(|(_, _, exact_match)| *exact_match) {
750                    tracing::trace!("Exact tag found only");
751                    if state.tags.is_some() {
752                        tracing::debug!("Closing tag mode: exact tag found");
753                        state.tags = None;
754                        state.commands.set_focus(true);
755                    }
756                    self.schedule_debounced_command_refresh();
757                } else {
758                    tracing::trace!("Found {} tags", tags.len());
759                    let tags = tags.into_iter().map(|(tag, _, _)| CommentString::from(tag)).collect();
760                    let tags_list = if let Some(ref mut list) = state.tags {
761                        list
762                    } else {
763                        tracing::debug!("Entering tag mode");
764                        state
765                            .tags
766                            .insert(CustomList::new(self.theme.clone(), self.inline, Vec::new()))
767                    };
768                    tags_list.update_items(tags, true);
769                    state.commands.set_focus(false);
770                }
771
772                Ok(())
773            }
774            Err(AppError::UserFacing(err)) => {
775                tracing::warn!("{err}");
776                state.error.set_perm_message(err.to_string());
777                if state.tags.is_some() {
778                    tracing::debug!("Closing tag mode");
779                    state.tags = None;
780                    state.commands.set_focus(true);
781                }
782                Ok(())
783            }
784            Err(AppError::Unexpected(err)) => Err(err),
785        }
786    }
787
788    /// Confirms the tag by replacing the editing tag with the selected one
789    #[instrument(skip_all)]
790    async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
791        // Find the start and end of the current tag by looking both sides of the cursor
792        let mut tag_start = cursor_pos.wrapping_sub(1);
793        let chars: Vec<_> = query.chars().collect();
794        while tag_start > 0 && chars[tag_start] != '#' {
795            tag_start -= 1;
796        }
797        let mut tag_end = cursor_pos;
798        while tag_end < chars.len() && chars[tag_end] != ' ' {
799            tag_end += 1;
800        }
801        let mut state = self.state.write();
802        if chars[tag_start] == '#' {
803            // Replace the partial tag with the selected one
804            state.query.select_all();
805            state.query.cut();
806            state
807                .query
808                .insert_str(format!("{}{} {}", &query[..tag_start], tag, &query[tag_end..]));
809            state
810                .query
811                .move_cursor(CursorMove::Jump(0, (tag_start + tag.len() + 1) as u16));
812        }
813        state.tags = None;
814        state.commands.set_focus(true);
815        self.schedule_debounced_command_refresh();
816        Ok(Action::NoOp)
817    }
818
819    /// Confirms the command by increasing the usage counter storing it and quits or switches to the variable
820    /// replacement component if needed
821    #[instrument(skip_all)]
822    async fn confirm_command(&mut self, command: Command, execute: bool, ai_command: bool) -> Result<Action> {
823        // Increment usage count
824        if !ai_command && command.source != SOURCE_WORKSPACE {
825            self.service
826                .increment_command_usage(command.id)
827                .await
828                .map_err(AppError::into_report)?;
829        }
830        // Determine if the command has some variables
831        let template = CommandTemplate::parse(&command.cmd, false);
832        if template.has_pending_variable() {
833            // If it does, switch to the variable replacement component
834            Ok(Action::SwitchComponent(Box::new(VariableReplacementComponent::new(
835                self.service.clone(),
836                self.theme.clone(),
837                self.inline,
838                execute,
839                false,
840                template,
841            ))))
842        } else if execute {
843            // If it doesn't and execute is true, execute the command
844            Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
845        } else {
846            // Otherwise just output it
847            Ok(Action::Quit(
848                ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
849            ))
850        }
851    }
852}