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#[derive(strum::EnumIs)]
30pub enum EditCompletionComponentMode {
31 New { ai: bool },
33 Edit { parent: Box<dyn Component> },
36 EditMemory {
39 parent: Box<dyn Component>,
40 callback: Arc<dyn Fn(VariableCompletion) -> Result<()> + Send + Sync>,
41 },
42 Empty,
44}
45
46pub struct EditCompletionComponent {
48 theme: Theme,
50 inline: bool,
52 service: IntelliShellService,
54 layout: Layout,
56 mode: EditCompletionComponentMode,
58 state: Arc<RwLock<EditCompletionComponentState<'static>>>,
60}
61struct EditCompletionComponentState<'a> {
62 completion: VariableCompletion,
64 active_field: ActiveField,
66 root_cmd: CustomTextArea<'a>,
68 variable: CustomTextArea<'a>,
70 suggestions_provider: CustomTextArea<'a>,
72 last_output: Option<Result<String, String>>,
74 is_dirty: bool,
76 error: ErrorPopup<'a>,
78}
79
80#[derive(Clone, Copy, PartialEq, Eq, EnumCycle)]
82enum ActiveField {
83 RootCmd,
84 Variable,
85 SuggestionsCommand,
86}
87
88impl EditCompletionComponent {
89 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 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 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 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 1 + 1 + 1 + 5
207 }
208
209 #[instrument(skip_all)]
210 async fn init_and_peek(&mut self) -> Result<Action> {
211 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 let [root_cmd_area, variable_area, suggestions_provider_area, output_area] = self.layout.areas(area);
226
227 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 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 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 match &self.mode {
272 EditCompletionComponentMode::New { .. } => Ok(Action::Quit(ProcessOutput::success())),
274 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 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 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 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 match &self.mode {
430 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 EditCompletionComponentMode::Edit { .. } => {
457 match self.service.update_variable_completion(completion).await {
458 Ok(_) => {
459 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 EditCompletionComponentMode::EditMemory { callback, .. } => {
482 callback(completion)?;
484
485 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 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 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}