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#[derive(strum::EnumIs)]
31pub enum EditCompletionComponentMode {
32 New { ai: bool },
34 Edit { parent: Box<dyn Component> },
37 EditMemory {
40 parent: Box<dyn Component>,
41 callback: Arc<dyn Fn(VariableCompletion) -> Result<()> + Send + Sync>,
42 },
43 Empty,
45}
46
47pub struct EditCompletionComponent {
49 theme: Theme,
51 inline: bool,
53 service: IntelliShellService,
55 layout: Layout,
57 mode: EditCompletionComponentMode,
59 global_cancellation_token: CancellationToken,
61 state: Arc<RwLock<EditCompletionComponentState<'static>>>,
63}
64struct EditCompletionComponentState<'a> {
65 completion: VariableCompletion,
67 active_field: ActiveField,
69 root_cmd: CustomTextArea<'a>,
71 variable: CustomTextArea<'a>,
73 suggestions_provider: CustomTextArea<'a>,
75 last_output: Option<Result<String, String>>,
77 is_dirty: bool,
79 error: ErrorPopup<'a>,
81}
82
83#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
85enum ActiveField {
86 RootCmd,
87 Variable,
88 SuggestionsCommand,
89}
90
91impl EditCompletionComponent {
92 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 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 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 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 1 + 1 + 1 + 5
212 }
213
214 #[instrument(skip_all)]
215 async fn init_and_peek(&mut self) -> Result<Action> {
216 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 let [root_cmd_area, variable_area, suggestions_provider_area, output_area] = self.layout.areas(area);
231
232 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 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 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 match &self.mode {
277 EditCompletionComponentMode::New { .. } => Ok(Action::Quit(ProcessOutput::success())),
279 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 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 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 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 match &self.mode {
435 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 EditCompletionComponentMode::Edit { .. } => {
462 match self.service.update_variable_completion(completion).await {
463 Ok(_) => {
464 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 EditCompletionComponentMode::EditMemory { callback, .. } => {
487 callback(completion)?;
489
490 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 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 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}