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