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