Skip to main content

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