Skip to main content

intelli_shell/component/
search.rs

1use std::{sync::Arc, time::Duration};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
6use enum_cycling::EnumCycle;
7use parking_lot::Mutex;
8use ratatui::{
9    Frame,
10    backend::FromCrossterm,
11    layout::{Constraint, Layout, Rect},
12    style::Style,
13};
14use ratatui_textarea::CursorMove;
15use tokio_util::sync::CancellationToken;
16use tracing::instrument;
17
18use super::Component;
19use crate::{
20    app::Action,
21    component::{
22        edit::{EditCommandComponent, EditCommandComponentMode},
23        variable::VariableReplacementComponent,
24    },
25    config::{Config, DestructiveConfig, KeyBindingsConfig, SearchConfig, Theme},
26    errors::AppError,
27    format_msg,
28    model::{Command, CommandTemplate, SOURCE_WORKSPACE, SearchMode},
29    process::ProcessOutput,
30    service::IntelliShellService,
31    widgets::{CustomList, CustomTextArea, ErrorPopup, NewVersionBanner, items::string::CommentString},
32};
33
34const EMPTY_STORAGE_MESSAGE: &str = r#"There are no stored commands yet!
35    - Try to bookmark some command with 'Ctrl + B'
36    - Or execute 'intelli-shell tldr fetch' to download a bunch of tldr's useful commands"#;
37
38/// A component for searching [`Command`]
39#[derive(Clone)]
40pub struct SearchCommandsComponent {
41    /// The visual theme for styling the component
42    theme: Theme,
43    /// The configuration for identifying destructive commands
44    destructive: DestructiveConfig,
45    /// Whether the TUI is in inline mode or not
46    inline: bool,
47    /// Whether to directly execute the command if it matches an alias exactly
48    exec_on_alias_match: bool,
49    /// Service for interacting with command storage
50    service: IntelliShellService,
51    /// The component layout
52    layout: Layout,
53    /// The delay before triggering a search after user input
54    search_delay: Duration,
55    /// Cancellation token for the current refresh task
56    refresh_token: Arc<Mutex<Option<CancellationToken>>>,
57    /// Global cancellation token
58    global_cancellation_token: CancellationToken,
59    /// The state of the component
60    state: Arc<Mutex<SearchCommandsComponentState<'static>>>,
61}
62struct SearchCommandsComponentState<'a> {
63    /// The next component initialization must prompt AI
64    initialize_with_ai: bool,
65    /// The default search mode
66    mode: SearchMode,
67    /// Whether to search for user commands only by default (excluding tldr)
68    user_only: bool,
69    /// The active query
70    query: CustomTextArea<'a>,
71    /// Whether ai mode is currently enabled
72    ai_mode: bool,
73    /// List of tags, if currently editing a tag
74    tags: Option<CustomList<'a, CommentString>>,
75    /// Whether the command search was an alias match
76    alias_match: bool,
77    /// The list of commands
78    commands: CustomList<'a, Command>,
79    /// Popup for displaying error messages
80    error: ErrorPopup<'a>,
81}
82
83impl SearchCommandsComponent {
84    /// Creates a new [`SearchCommandsComponent`]
85    pub fn new(
86        service: IntelliShellService,
87        config: Config,
88        inline: bool,
89        query: impl Into<String>,
90        initialize_with_ai: bool,
91        cancellation_token: CancellationToken,
92    ) -> Self {
93        let query =
94            CustomTextArea::new(Style::from_crossterm(config.theme.primary), inline, false, query.into()).focused();
95
96        let commands = CustomList::new(config.theme.clone(), inline, Vec::new());
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.clone(),
115            destructive: config.destructive,
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(Mutex::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.lock();
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.lock().initialize_with_ai;
183        if initialize_with_ai {
184            let res = self.prompt_ai().await;
185            self.state.lock().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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.lock();
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.destructive.clone(),
520                    self.inline,
521                    command,
522                    EditCommandComponentMode::Edit {
523                        parent: Box::new(self.clone()),
524                    },
525                    self.global_cancellation_token.clone(),
526                ))))
527            } else {
528                self.state
529                    .lock()
530                    .error
531                    .set_temp_message("Workspace commands can't be updated");
532                Ok(Action::NoOp)
533            }
534        } else {
535            Ok(Action::NoOp)
536        }
537    }
538
539    #[instrument(skip_all)]
540    async fn selection_confirm(&mut self) -> Result<Action> {
541        let (selected_tag, cursor_pos, query, command, ai_mode) = {
542            let state = self.state.lock();
543            if state.query.is_ai_loading() {
544                return Ok(Action::NoOp);
545            }
546            let selected_tag = state.tags.as_ref().and_then(|s| s.selected().cloned());
547            (
548                selected_tag.map(String::from),
549                state.query.cursor().1,
550                state.query.lines_as_string(),
551                state.commands.selected().cloned(),
552                state.ai_mode,
553            )
554        };
555
556        if let Some(tag) = selected_tag {
557            tracing::debug!("Selected tag: {tag}");
558            self.confirm_tag(tag, query, cursor_pos).await
559        } else if let Some(command) = command {
560            tracing::info!("Selected command: {}", command.cmd);
561            self.confirm_command(command, false, ai_mode).await
562        } else {
563            Ok(Action::NoOp)
564        }
565    }
566
567    #[instrument(skip_all)]
568    async fn selection_execute(&mut self) -> Result<Action> {
569        let (command, ai_mode) = {
570            let state = self.state.lock();
571            if state.query.is_ai_loading() {
572                return Ok(Action::NoOp);
573            }
574            (state.commands.selected().cloned(), state.ai_mode)
575        };
576        if let Some(command) = command {
577            tracing::info!("Selected command to execute: {}", command.cmd);
578            self.confirm_command(command, true, ai_mode).await
579        } else {
580            Ok(Action::NoOp)
581        }
582    }
583
584    async fn prompt_ai(&mut self) -> Result<Action> {
585        let mut state = self.state.lock();
586        if state.tags.is_some() || state.query.is_ai_loading() {
587            return Ok(Action::NoOp);
588        }
589        let query = state.query.lines_as_string();
590        if !query.is_empty() {
591            state.query.set_ai_loading(true);
592            drop(state);
593            self.update_config(None, None, Some(true));
594            let this = self.clone();
595            tokio::spawn(async move {
596                let res = this
597                    .service
598                    .suggest_commands(&query, this.global_cancellation_token.clone())
599                    .await;
600                let mut state = this.state.lock();
601                let mut commands = match res {
602                    Ok(suggestions) => {
603                        if !suggestions.is_empty() {
604                            state.error.clear_message();
605                            state.alias_match = false;
606                            suggestions
607                        } else {
608                            state
609                                .error
610                                .set_temp_message("AI did not return any suggestion".to_string());
611                            Vec::new()
612                        }
613                    }
614                    Err(AppError::UserFacing(err)) => {
615                        tracing::warn!("{err}");
616                        state.error.set_temp_message(err.to_string());
617                        Vec::new()
618                    }
619                    Err(AppError::Unexpected(err)) => panic!("Error prompting for command suggestions: {err:?}"),
620                };
621                for cmd in &mut commands {
622                    cmd.update_is_destructive(&this.destructive.patterns);
623                }
624                state.commands.update_items(commands, true);
625                state.query.set_ai_loading(false);
626            });
627        }
628        Ok(Action::NoOp)
629    }
630}
631
632impl SearchCommandsComponent {
633    /// Schedule a debounced refresh of the commands list
634    fn schedule_debounced_command_refresh(&self) {
635        let cancellation_token = {
636            // Cancel previous token (if any)
637            let mut token_guard = self.refresh_token.lock();
638            if let Some(token) = token_guard.take() {
639                token.cancel();
640            }
641            // Issue a new one
642            let new_token = CancellationToken::new();
643            *token_guard = Some(new_token.clone());
644            new_token
645        };
646
647        // Spawn a new task
648        let this = self.clone();
649        tokio::spawn(async move {
650            tokio::select! {
651                biased;
652                // That completes when the token is canceled
653                _ = cancellation_token.cancelled() => {}
654                // Or performs a command search after the configured delay
655                _ = tokio::time::sleep(this.search_delay) => {
656                    if let Err(err) = this.refresh_commands().await {
657                        panic!("Error refreshing commands: {err:?}");
658                    }
659                }
660            }
661        });
662    }
663
664    /// Refresh the command list
665    #[instrument(skip_all)]
666    async fn refresh_commands(&self) -> Result<()> {
667        // Retrieve the user query
668        let (mode, user_only, ai_mode, query) = {
669            let state = self.state.lock();
670            (
671                state.mode,
672                state.user_only,
673                state.ai_mode,
674                state.query.lines_as_string(),
675            )
676        };
677
678        // Skip when ai mode is enabled
679        if ai_mode {
680            return Ok(());
681        }
682
683        // Search for commands
684        let res = self.service.search_commands(mode, user_only, &query).await;
685
686        // Update the command list or display an error
687        let mut state = self.state.lock();
688        let mut commands = match res {
689            Ok((commands, alias_match)) => {
690                state.error.clear_message();
691                state.alias_match = alias_match;
692                commands
693            }
694            Err(AppError::UserFacing(err)) => {
695                tracing::warn!("{err}");
696                state.error.set_perm_message(err.to_string());
697                Vec::new()
698            }
699            Err(AppError::Unexpected(err)) => return Err(err),
700        };
701        for cmd in &mut commands {
702            cmd.update_is_destructive(&self.destructive.patterns);
703        }
704        state.commands.update_items(commands, true);
705
706        Ok(())
707    }
708
709    /// Immediately starts a debounced refresh of the tags list
710    fn debounced_refresh_tags(&self) {
711        let this = self.clone();
712        tokio::spawn(async move {
713            if let Err(err) = this.refresh_tags().await {
714                panic!("Error refreshing tags: {err:?}");
715            }
716        });
717    }
718
719    /// Refresh the suggested tags list
720    #[instrument(skip_all)]
721    async fn refresh_tags(&self) -> Result<()> {
722        // Retrieve the user query
723        let (mode, user_only, ai_mode, query, cursor_pos) = {
724            let state = self.state.lock();
725            (
726                state.mode,
727                state.user_only,
728                state.ai_mode,
729                state.query.lines_as_string(),
730                state.query.cursor().1,
731            )
732        };
733
734        // Skip when ai mode is enabled
735        if ai_mode {
736            return Ok(());
737        }
738
739        // Find tags for that query
740        let res = self.service.search_tags(mode, user_only, &query, cursor_pos).await;
741
742        // Update the tags list
743        let mut state = self.state.lock();
744        match res {
745            Ok(None) => {
746                tracing::trace!("No editing tags");
747                if state.tags.is_some() {
748                    tracing::debug!("Closing tag mode: no editing tag");
749                    state.tags = None;
750                    state.commands.set_focus(true);
751                }
752                self.schedule_debounced_command_refresh();
753                Ok(())
754            }
755            Ok(Some(tags)) if tags.is_empty() => {
756                tracing::trace!("No tags found");
757                if state.tags.is_some() {
758                    tracing::debug!("Closing tag mode: no tags found");
759                    state.tags = None;
760                    state.commands.set_focus(true);
761                }
762                self.schedule_debounced_command_refresh();
763                Ok(())
764            }
765            Ok(Some(tags)) => {
766                state.error.clear_message();
767                if tags.len() == 1 && tags.iter().all(|(_, _, exact_match)| *exact_match) {
768                    tracing::trace!("Exact tag found only");
769                    if state.tags.is_some() {
770                        tracing::debug!("Closing tag mode: exact tag found");
771                        state.tags = None;
772                        state.commands.set_focus(true);
773                    }
774                    self.schedule_debounced_command_refresh();
775                } else {
776                    tracing::trace!("Found {} tags", tags.len());
777                    let tags = tags.into_iter().map(|(tag, _, _)| CommentString::from(tag)).collect();
778                    let tags_list = if let Some(ref mut list) = state.tags {
779                        list
780                    } else {
781                        tracing::debug!("Entering tag mode");
782                        state
783                            .tags
784                            .insert(CustomList::new(self.theme.clone(), self.inline, Vec::new()))
785                    };
786                    tags_list.update_items(tags, true);
787                    state.commands.set_focus(false);
788                }
789
790                Ok(())
791            }
792            Err(AppError::UserFacing(err)) => {
793                tracing::warn!("{err}");
794                state.error.set_perm_message(err.to_string());
795                if state.tags.is_some() {
796                    tracing::debug!("Closing tag mode");
797                    state.tags = None;
798                    state.commands.set_focus(true);
799                }
800                Ok(())
801            }
802            Err(AppError::Unexpected(err)) => Err(err),
803        }
804    }
805
806    /// Confirms the tag by replacing the editing tag with the selected one
807    #[instrument(skip_all)]
808    async fn confirm_tag(&mut self, tag: String, query: String, cursor_pos: usize) -> Result<Action> {
809        // Find the start and end of the current tag by looking both sides of the cursor
810        let mut tag_start = cursor_pos.wrapping_sub(1);
811        let chars: Vec<_> = query.chars().collect();
812        while tag_start > 0 && chars[tag_start] != '#' {
813            tag_start -= 1;
814        }
815        let mut tag_end = cursor_pos;
816        while tag_end < chars.len() && chars[tag_end] != ' ' {
817            tag_end += 1;
818        }
819        let mut state = self.state.lock();
820        if chars[tag_start] == '#' {
821            // Replace the partial tag with the selected one
822            state.query.select_all();
823            state.query.cut();
824            state
825                .query
826                .insert_str(format!("{}{} {}", &query[..tag_start], tag, &query[tag_end..]));
827            state
828                .query
829                .move_cursor(CursorMove::Jump(0, (tag_start + tag.len() + 1) as u16));
830        }
831        state.tags = None;
832        state.commands.set_focus(true);
833        self.schedule_debounced_command_refresh();
834        Ok(Action::NoOp)
835    }
836
837    /// Confirms the command by increasing the usage counter storing it and quits or switches to the variable
838    /// replacement component if needed
839    #[instrument(skip_all)]
840    async fn confirm_command(&mut self, command: Command, execute: bool, ai_command: bool) -> Result<Action> {
841        // Increment usage count
842        if !ai_command && command.source != SOURCE_WORKSPACE {
843            self.service
844                .increment_command_usage(command.id)
845                .await
846                .map_err(AppError::into_report)?;
847        }
848        // Determine if the command has some variables
849        let template = CommandTemplate::parse(&command.cmd, false);
850        if template.has_pending_variable() {
851            // If it does, switch to the variable replacement component
852            Ok(Action::SwitchComponent(Box::new(VariableReplacementComponent::new(
853                self.service.clone(),
854                self.theme.clone(),
855                self.inline,
856                execute,
857                false,
858                template,
859                self.global_cancellation_token.clone(),
860            ))))
861        } else if execute {
862            // If it doesn't and execute is true, execute the command
863            Ok(Action::Quit(ProcessOutput::execute(command.cmd)))
864        } else {
865            // Otherwise just output it
866            Ok(Action::Quit(
867                ProcessOutput::success().stdout(&command.cmd).fileout(command.cmd),
868            ))
869        }
870    }
871}