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