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