intelli_shell/component/
completion_edit.rs

1use std::{mem, sync::Arc};
2
3use async_trait::async_trait;
4use color_eyre::Result;
5use enum_cycling::EnumCycle;
6use itertools::Itertools;
7use parking_lot::RwLock;
8use ratatui::{
9    Frame,
10    layout::{Alignment, Constraint, Layout, Rect},
11    widgets::{Block, Borders, Paragraph, Wrap},
12};
13use tracing::instrument;
14
15use super::Component;
16use crate::{
17    app::Action,
18    config::Theme,
19    errors::AppError,
20    format_msg,
21    model::VariableCompletion,
22    process::ProcessOutput,
23    service::{FORBIDDEN_COMPLETION_ROOT_CMD_CHARS, FORBIDDEN_COMPLETION_VARIABLE_CHARS, IntelliShellService},
24    utils::resolve_completion,
25    widgets::{CustomTextArea, ErrorPopup, NewVersionBanner},
26};
27
28/// Defines the operational mode of the [`EditCompletionComponent`]
29#[derive(strum::EnumIs)]
30pub enum EditCompletionComponentMode {
31    /// The component is used to create a new completion
32    New { ai: bool },
33    /// The component is used to edit an existing completion
34    /// It holds the parent component to switch back to after editing is complete
35    Edit { parent: Box<dyn Component> },
36    /// The component is used to edit a completion in memory.
37    /// It holds the parent component to switch back to after editing is complete and a callback to run.
38    EditMemory {
39        parent: Box<dyn Component>,
40        callback: Arc<dyn Fn(VariableCompletion) -> Result<()> + Send + Sync>,
41    },
42    /// A special case to be used in mem::replace to extract the owned variables
43    Empty,
44}
45
46/// A component for creating or editing a [`VariableCompletion`]
47pub struct EditCompletionComponent {
48    /// The visual theme for styling the component
49    theme: Theme,
50    /// Whether the TUI is in inline mode or not
51    inline: bool,
52    /// Service for interacting with storage
53    service: IntelliShellService,
54    /// The layout for arranging the input fields
55    layout: Layout,
56    /// The operational mode
57    mode: EditCompletionComponentMode,
58    /// The state of the component
59    state: Arc<RwLock<EditCompletionComponentState<'static>>>,
60}
61struct EditCompletionComponentState<'a> {
62    /// The completion being edited or created
63    completion: VariableCompletion,
64    /// The currently focused input field
65    active_field: ActiveField,
66    /// Text area for the completion root cmd
67    root_cmd: CustomTextArea<'a>,
68    /// Text area for the completion variable
69    variable: CustomTextArea<'a>,
70    /// Text area for the command to provide suggestions
71    suggestions_provider: CustomTextArea<'a>,
72    /// The output of the last execution
73    last_output: Option<Result<String, String>>,
74    /// A flag to track if the text content has been modified since the last test
75    is_dirty: bool,
76    /// Popup for displaying error messages
77    error: ErrorPopup<'a>,
78}
79
80/// Represents the currently active (focused) input field
81#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
82enum ActiveField {
83    RootCmd,
84    Variable,
85    SuggestionsCommand,
86}
87
88impl EditCompletionComponent {
89    /// Creates a new [`EditCompletionComponent`]
90    pub fn new(
91        service: IntelliShellService,
92        theme: Theme,
93        inline: bool,
94        completion: VariableCompletion,
95        mode: EditCompletionComponentMode,
96    ) -> Self {
97        let mut root_cmd = CustomTextArea::new(theme.secondary, inline, false, "")
98            .title(if inline { "Command:" } else { " Command " })
99            .forbidden_chars_regex(FORBIDDEN_COMPLETION_ROOT_CMD_CHARS.clone())
100            .focused();
101        let mut variable = CustomTextArea::new(theme.primary, inline, false, "")
102            .title(if inline { "Variable:" } else { " Variable " })
103            .forbidden_chars_regex(FORBIDDEN_COMPLETION_VARIABLE_CHARS.clone())
104            .focused();
105        let mut suggestions_provider = CustomTextArea::new(theme.primary, inline, false, "")
106            .title(if inline {
107                "Suggestions Provider:"
108            } else {
109                " Suggestions Provider "
110            })
111            .focused();
112
113        root_cmd.insert_str(&completion.root_cmd);
114        variable.insert_str(&completion.variable);
115        suggestions_provider.insert_str(&completion.suggestions_provider);
116
117        let active_field = if completion.root_cmd.is_empty() && completion.variable.is_empty() {
118            root_cmd.set_focus(true);
119            variable.set_focus(false);
120            suggestions_provider.set_focus(false);
121            ActiveField::RootCmd
122        } else if completion.variable.is_empty() {
123            root_cmd.set_focus(false);
124            variable.set_focus(true);
125            suggestions_provider.set_focus(false);
126            ActiveField::Variable
127        } else {
128            root_cmd.set_focus(false);
129            variable.set_focus(false);
130            suggestions_provider.set_focus(true);
131            ActiveField::SuggestionsCommand
132        };
133
134        let error = ErrorPopup::empty(&theme);
135
136        let layout = if inline {
137            Layout::vertical([
138                Constraint::Length(1),
139                Constraint::Length(1),
140                Constraint::Length(1),
141                Constraint::Min(3),
142            ])
143        } else {
144            Layout::vertical([
145                Constraint::Length(3),
146                Constraint::Length(3),
147                Constraint::Length(3),
148                Constraint::Min(3),
149            ])
150            .margin(1)
151        };
152
153        Self {
154            theme,
155            inline,
156            service,
157            layout,
158            mode,
159            state: Arc::new(RwLock::new(EditCompletionComponentState {
160                completion,
161                active_field,
162                root_cmd,
163                variable,
164                suggestions_provider,
165                last_output: None,
166                is_dirty: true,
167                error,
168            })),
169        }
170    }
171}
172impl<'a> EditCompletionComponentState<'a> {
173    /// Returns a mutable reference to the currently active input
174    fn active_input(&mut self) -> &mut CustomTextArea<'a> {
175        match self.active_field {
176            ActiveField::RootCmd => &mut self.root_cmd,
177            ActiveField::Variable => &mut self.variable,
178            ActiveField::SuggestionsCommand => &mut self.suggestions_provider,
179        }
180    }
181
182    /// Updates the focus state of the input fields based on `active_field`
183    fn update_focus(&mut self) {
184        self.root_cmd.set_focus(false);
185        self.variable.set_focus(false);
186        self.suggestions_provider.set_focus(false);
187
188        self.active_input().set_focus(true);
189    }
190
191    /// Marks the completion as dirty, that means the provider has to be tested before completion
192    fn mark_as_dirty(&mut self) {
193        self.is_dirty = true;
194        self.last_output = None;
195    }
196}
197
198#[async_trait]
199impl Component for EditCompletionComponent {
200    fn name(&self) -> &'static str {
201        "CompletionEditComponent"
202    }
203
204    fn min_inline_height(&self) -> u16 {
205        // Root Cmd + Variable + Suggestions Provider + Output
206        1 + 1 + 1 + 5
207    }
208
209    #[instrument(skip_all)]
210    async fn init_and_peek(&mut self) -> Result<Action> {
211        // If AI mode is enabled, prompt for command suggestions
212        if let EditCompletionComponentMode::New { ai } = &self.mode
213            && *ai
214        {
215            self.prompt_ai().await?;
216        }
217        Ok(Action::NoOp)
218    }
219
220    #[instrument(skip_all)]
221    fn render(&mut self, frame: &mut Frame, area: Rect) {
222        let mut state = self.state.write();
223
224        // Split the area according to the layout
225        let [root_cmd_area, variable_area, suggestions_provider_area, output_area] = self.layout.areas(area);
226
227        // Render widgets
228        frame.render_widget(&state.root_cmd, root_cmd_area);
229        frame.render_widget(&state.variable, variable_area);
230        frame.render_widget(&state.suggestions_provider, suggestions_provider_area);
231
232        // Render the output
233        if let Some(out) = &state.last_output {
234            let is_err = out.is_err();
235            let (output, style) = match out {
236                Ok(o) => (o, self.theme.secondary),
237                Err(err) => (err, self.theme.error),
238            };
239            let output_paragraph = Paragraph::new(output.as_str())
240                .style(style)
241                .block(
242                    Block::default()
243                        .borders(Borders::ALL)
244                        .title(" Preview ")
245                        .title_alignment(if self.inline { Alignment::Right } else { Alignment::Left })
246                        .style(style),
247                )
248                .wrap(Wrap { trim: is_err });
249            frame.render_widget(output_paragraph, output_area);
250        }
251
252        // Render the new version banner and error message as an overlay
253        if let Some(new_version) = self.service.poll_new_version() {
254            NewVersionBanner::new(&self.theme, new_version).render_in(frame, area);
255        }
256        state.error.render_in(frame, area);
257    }
258
259    fn tick(&mut self) -> Result<Action> {
260        let mut state = self.state.write();
261        state.error.tick();
262        state.root_cmd.tick();
263        state.variable.tick();
264        state.suggestions_provider.tick();
265
266        Ok(Action::NoOp)
267    }
268
269    fn exit(&mut self) -> Result<Action> {
270        // Based on the component mode
271        match &self.mode {
272            // Quit the component without saving
273            EditCompletionComponentMode::New { .. } => Ok(Action::Quit(ProcessOutput::success())),
274            // Switch back to the parent, without storing the completion
275            EditCompletionComponentMode::Edit { .. } => Ok(Action::SwitchComponent(
276                match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
277                    EditCompletionComponentMode::Edit { parent } => parent,
278                    EditCompletionComponentMode::Empty
279                    | EditCompletionComponentMode::New { .. }
280                    | EditCompletionComponentMode::EditMemory { .. } => {
281                        unreachable!()
282                    }
283                },
284            )),
285            // Switch back to the parent, without calling the callback
286            EditCompletionComponentMode::EditMemory { .. } => Ok(Action::SwitchComponent(
287                match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
288                    EditCompletionComponentMode::EditMemory { parent, .. } => parent,
289                    EditCompletionComponentMode::Empty
290                    | EditCompletionComponentMode::New { .. }
291                    | EditCompletionComponentMode::Edit { .. } => {
292                        unreachable!()
293                    }
294                },
295            )),
296            // This should never happen, but just in case
297            EditCompletionComponentMode::Empty => Ok(Action::NoOp),
298        }
299    }
300
301    fn move_up(&mut self) -> Result<Action> {
302        let mut state = self.state.write();
303        if !state.active_input().is_ai_loading() {
304            state.active_field = state.active_field.up();
305            state.update_focus();
306        }
307
308        Ok(Action::NoOp)
309    }
310
311    fn move_down(&mut self) -> Result<Action> {
312        let mut state = self.state.write();
313        if !state.active_input().is_ai_loading() {
314            state.active_field = state.active_field.down();
315            state.update_focus();
316        }
317
318        Ok(Action::NoOp)
319    }
320
321    fn move_left(&mut self, word: bool) -> Result<Action> {
322        let mut state = self.state.write();
323        state.active_input().move_cursor_left(word);
324
325        Ok(Action::NoOp)
326    }
327
328    fn move_right(&mut self, word: bool) -> Result<Action> {
329        let mut state = self.state.write();
330        state.active_input().move_cursor_right(word);
331
332        Ok(Action::NoOp)
333    }
334
335    fn move_prev(&mut self) -> Result<Action> {
336        self.move_up()
337    }
338
339    fn move_next(&mut self) -> Result<Action> {
340        self.move_down()
341    }
342
343    fn move_home(&mut self, absolute: bool) -> Result<Action> {
344        let mut state = self.state.write();
345        state.active_input().move_home(absolute);
346
347        Ok(Action::NoOp)
348    }
349
350    fn move_end(&mut self, absolute: bool) -> Result<Action> {
351        let mut state = self.state.write();
352        state.active_input().move_end(absolute);
353
354        Ok(Action::NoOp)
355    }
356
357    fn undo(&mut self) -> Result<Action> {
358        let mut state = self.state.write();
359        state.active_input().undo();
360        state.mark_as_dirty();
361
362        Ok(Action::NoOp)
363    }
364
365    fn redo(&mut self) -> Result<Action> {
366        let mut state = self.state.write();
367        state.active_input().redo();
368        state.mark_as_dirty();
369
370        Ok(Action::NoOp)
371    }
372
373    fn insert_text(&mut self, text: String) -> Result<Action> {
374        let mut state = self.state.write();
375        state.active_input().insert_str(text);
376        state.mark_as_dirty();
377
378        Ok(Action::NoOp)
379    }
380
381    fn insert_char(&mut self, c: char) -> Result<Action> {
382        let mut state = self.state.write();
383        state.active_input().insert_char(c);
384        state.mark_as_dirty();
385
386        Ok(Action::NoOp)
387    }
388
389    fn insert_newline(&mut self) -> Result<Action> {
390        let mut state = self.state.write();
391        state.active_input().insert_newline();
392        state.mark_as_dirty();
393
394        Ok(Action::NoOp)
395    }
396
397    fn delete(&mut self, backspace: bool, word: bool) -> Result<Action> {
398        let mut state = self.state.write();
399        state.active_input().delete(backspace, word);
400        state.mark_as_dirty();
401
402        Ok(Action::NoOp)
403    }
404
405    #[instrument(skip_all)]
406    async fn selection_confirm(&mut self) -> Result<Action> {
407        let completion = {
408            let mut state = self.state.write();
409            if state.active_input().is_ai_loading() {
410                return Ok(Action::NoOp);
411            }
412
413            // Update the completion with the input data
414            state
415                .completion
416                .clone()
417                .with_root_cmd(state.root_cmd.lines_as_string())
418                .with_variable(state.variable.lines_as_string())
419                .with_suggestions_provider(state.suggestions_provider.lines_as_string())
420        };
421
422        if self.state.read().is_dirty {
423            self.test_provider_command(&completion).await?;
424            self.state.write().is_dirty = false;
425            return Ok(Action::NoOp);
426        }
427
428        // Based on the component mode
429        match &self.mode {
430            // Insert the new completion
431            EditCompletionComponentMode::New { .. } => {
432                match self.service.create_variable_completion(completion).await {
433                    Ok(c) if c.is_global() => Ok(Action::Quit(ProcessOutput::success().stderr(format_msg!(
434                        self.theme,
435                        "Completion for global {} variable stored: {}",
436                        self.theme.secondary.apply(&c.flat_variable),
437                        self.theme.secondary.apply(&c.suggestions_provider)
438                    )))),
439                    Ok(c) => Ok(Action::Quit(ProcessOutput::success().stderr(format_msg!(
440                        self.theme,
441                        "Completion for {} variable within {} commands stored: {}",
442                        self.theme.secondary.apply(&c.flat_variable),
443                        self.theme.secondary.apply(&c.flat_root_cmd),
444                        self.theme.secondary.apply(&c.suggestions_provider)
445                    )))),
446                    Err(AppError::UserFacing(err)) => {
447                        tracing::warn!("{err}");
448                        let mut state = self.state.write();
449                        state.error.set_temp_message(err.to_string());
450                        Ok(Action::NoOp)
451                    }
452                    Err(AppError::Unexpected(report)) => Err(report),
453                }
454            }
455            // Edit the existing completion
456            EditCompletionComponentMode::Edit { .. } => {
457                match self.service.update_variable_completion(completion).await {
458                    Ok(_) => {
459                        // Extract the owned parent component, leaving a placeholder on its place
460                        Ok(Action::SwitchComponent(
461                            match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
462                                EditCompletionComponentMode::Edit { parent } => parent,
463                                EditCompletionComponentMode::Empty
464                                | EditCompletionComponentMode::New { .. }
465                                | EditCompletionComponentMode::EditMemory { .. } => {
466                                    unreachable!()
467                                }
468                            },
469                        ))
470                    }
471                    Err(AppError::UserFacing(err)) => {
472                        tracing::warn!("{err}");
473                        let mut state = self.state.write();
474                        state.error.set_temp_message(err.to_string());
475                        Ok(Action::NoOp)
476                    }
477                    Err(AppError::Unexpected(report)) => Err(report),
478                }
479            }
480            // Edit the completion in memory and run the callback
481            EditCompletionComponentMode::EditMemory { callback, .. } => {
482                // Run the callback
483                callback(completion)?;
484
485                // Extract the owned parent component, leaving a placeholder on its place
486                Ok(Action::SwitchComponent(
487                    match mem::replace(&mut self.mode, EditCompletionComponentMode::Empty) {
488                        EditCompletionComponentMode::EditMemory { parent, .. } => parent,
489                        EditCompletionComponentMode::Empty
490                        | EditCompletionComponentMode::New { .. }
491                        | EditCompletionComponentMode::Edit { .. } => {
492                            unreachable!()
493                        }
494                    },
495                ))
496            }
497            // This should never happen, but just in case
498            EditCompletionComponentMode::Empty => Ok(Action::NoOp),
499        }
500    }
501
502    async fn selection_execute(&mut self) -> Result<Action> {
503        self.selection_confirm().await
504    }
505
506    async fn prompt_ai(&mut self) -> Result<Action> {
507        let mut state = self.state.write();
508        if state.active_field != ActiveField::SuggestionsCommand || state.active_input().is_ai_loading() {
509            return Ok(Action::NoOp);
510        }
511
512        let root_cmd = state.root_cmd.lines_as_string();
513        let variable = state.variable.lines_as_string();
514        let suggestions_provider = state.suggestions_provider.lines_as_string();
515
516        state.suggestions_provider.set_ai_loading(true);
517        let cloned_service = self.service.clone();
518        let cloned_state = self.state.clone();
519        tokio::spawn(async move {
520            let res = cloned_service
521                .suggest_completion(&root_cmd, &variable, &suggestions_provider)
522                .await;
523            let mut state = cloned_state.write();
524            state.suggestions_provider.set_ai_loading(false);
525            match res {
526                Ok(s) if s.is_empty() => {
527                    state.error.set_temp_message("AI generated an empty response");
528                }
529                Ok(suggestion) => {
530                    if !suggestions_provider.is_empty() {
531                        state.suggestions_provider.select_all();
532                        state.suggestions_provider.cut();
533                    }
534                    state.suggestions_provider.insert_str(&suggestion);
535                    state.mark_as_dirty();
536                }
537                Err(AppError::UserFacing(err)) => {
538                    tracing::warn!("{err}");
539                    state.error.set_temp_message(err.to_string());
540                }
541                Err(AppError::Unexpected(err)) => {
542                    panic!("Error prompting for completion suggestions: {err:?}")
543                }
544            }
545        });
546
547        Ok(Action::NoOp)
548    }
549}
550
551impl EditCompletionComponent {
552    /// Runs the provider command and updates the state with the output
553    async fn test_provider_command(&mut self, completion: &VariableCompletion) -> Result<bool> {
554        match resolve_completion(completion, None).await {
555            Ok(suggestions) if suggestions.is_empty() => {
556                let mut state = self.state.write();
557                state.last_output = Some(Ok("... empty output ...".to_string()));
558                Ok(true)
559            }
560            Ok(suggestions) => {
561                let mut state = self.state.write();
562                state.last_output = Some(Ok(suggestions.iter().join("\n")));
563                Ok(true)
564            }
565            Err(err) => {
566                let mut state = self.state.write();
567                state.last_output = Some(Err(err));
568                Ok(false)
569            }
570        }
571    }
572}