Skip to main content

intelli_shell/component/
search.rs

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