diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-10 13:24:57 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-10 20:24:57 +0000 |
| commit | 09279a428659cf41824737d3e0c97bcc19a8885a (patch) | |
| tree | 64731502c065df2483e8dd680d46c5559f3094f2 /crates/atuin-ai/src/tui/components | |
| parent | feat: add strip_trailing_whitespace, on by default (#3390) (diff) | |
| download | atuin-09279a428659cf41824737d3e0c97bcc19a8885a.zip | |
feat: Client-tool execution + permission system (#3370)
Adds client-side tool execution to Atuin AI, starting with
`atuin_history`. The server can request tool calls, which are executed
locally with a permission system, and results are sent back to continue
the conversation.
Diffstat (limited to 'crates/atuin-ai/src/tui/components')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 16 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/markdown.rs | 47 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 7 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/select.rs | 96 |
4 files changed, 135 insertions, 31 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs index fab29502..c04ac722 100644 --- a/crates/atuin-ai/src/tui/components/atuin_ai.rs +++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs @@ -22,10 +22,11 @@ pub(crate) struct AtuinAi { pub has_command: bool, pub is_input_blank: bool, pub pending_confirmation: bool, + pub has_executing_preview: bool, } #[derive(Default)] -pub struct AtuinAiState { +pub(crate) struct AtuinAiState { tx: Option<mpsc::Sender<AiTuiEvent>>, } @@ -55,15 +56,24 @@ fn atuin_ai( return EventResult::Ignored; }; - // Ctrl+C always exits + // Ctrl+C — interrupt executing command or exit if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') { - let _ = tx.send(AiTuiEvent::Exit); + if props.has_executing_preview { + let _ = tx.send(AiTuiEvent::InterruptToolExecution); + } else { + let _ = tx.send(AiTuiEvent::Exit); + } return EventResult::Consumed; } match props.mode { AppMode::Input => match code { KeyCode::Esc => { + if props.has_executing_preview { + let _ = tx.send(AiTuiEvent::InterruptToolExecution); + return EventResult::Consumed; + } + if props.pending_confirmation { let _ = tx.send(AiTuiEvent::CancelConfirmation); return EventResult::Consumed; diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs index 1cd7dbcf..f164fdc5 100644 --- a/crates/atuin-ai/src/tui/components/markdown.rs +++ b/crates/atuin-ai/src/tui/components/markdown.rs @@ -16,20 +16,12 @@ use ratatui_widgets::paragraph::{Paragraph, Wrap}; /// A markdown rendering component backed by pulldown-cmark. #[props] -pub struct Markdown { +pub(crate) struct Markdown { pub source: String, } -impl Markdown { - pub fn new(source: impl Into<String>) -> Self { - Self { - source: source.into(), - } - } -} - /// Style configuration for markdown rendering. -pub struct MarkdownStyles { +pub(crate) struct MarkdownStyles { pub base: Style, pub code_inline: Style, pub code_block: Style, @@ -98,26 +90,22 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat let mut style_stack: Vec<Style> = vec![styles.base]; let mut in_code_block = false; + let mut in_list_item = false; + // True until the first paragraph inside a list item has been opened. + // The first paragraph should flow inline with the "- " prefix. + let mut list_item_first_para = false; for event in parser { match event { Event::Start(Tag::Strong) => { - let bold = style_stack - .last() - .copied() - .unwrap_or(styles.base) - .add_modifier(Modifier::BOLD); + let bold = style_stack.last().copied().unwrap_or(styles.bold); style_stack.push(bold); } Event::End(TagEnd::Strong) => { style_stack.pop(); } Event::Start(Tag::Emphasis) => { - let italic = style_stack - .last() - .copied() - .unwrap_or(styles.base) - .add_modifier(Modifier::ITALIC); + let italic = style_stack.last().copied().unwrap_or(styles.italic); style_stack.push(italic); } Event::End(TagEnd::Emphasis) => { @@ -170,12 +158,17 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat lines.push(Vec::new()); } Event::Start(Tag::Paragraph) => { - if current_line > 0 || !lines[0].is_empty() { - // Two line advances: one to end the current line, one for a blank separator. - current_line += 1; - lines.push(Vec::new()); + if in_list_item && list_item_first_para { + // First paragraph flows inline with the "- " prefix + list_item_first_para = false; + } else if current_line > 0 || !lines[0].is_empty() { current_line += 1; lines.push(Vec::new()); + if !in_list_item { + // Blank separator between paragraphs (but not inside list items) + current_line += 1; + lines.push(Vec::new()); + } } } Event::End(TagEnd::Paragraph) => {} @@ -197,8 +190,12 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat lines.push(Vec::new()); } lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray))); + in_list_item = true; + list_item_first_para = true; + } + Event::End(TagEnd::Item) => { + in_list_item = false; } - Event::End(TagEnd::Item) => {} Event::Start(Tag::List(_)) => { if current_line > 0 || !lines[0].is_empty() { current_line += 1; diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs index 2f684f5f..3458327d 100644 --- a/crates/atuin-ai/src/tui/components/mod.rs +++ b/crates/atuin-ai/src/tui/components/mod.rs @@ -1,3 +1,4 @@ -pub mod atuin_ai; -pub mod input_box; -pub mod markdown; +pub(crate) mod atuin_ai; +pub(crate) mod input_box; +pub(crate) mod markdown; +pub(crate) mod select; diff --git a/crates/atuin-ai/src/tui/components/select.rs b/crates/atuin-ai/src/tui/components/select.rs new file mode 100644 index 00000000..5abbe655 --- /dev/null +++ b/crates/atuin-ai/src/tui/components/select.rs @@ -0,0 +1,96 @@ +use std::sync::mpsc; + +use crossterm::event::KeyCode; +use eye_declare::{Elements, EventResult, Hooks, Span, Text, View, component, element, props}; +use ratatui::style::Style; +use typed_builder::TypedBuilder; + +use crate::tui::events::AiTuiEvent; + +type OnSelectFn = Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync + 'static>; + +#[derive(TypedBuilder)] +pub(crate) struct SelectOption { + #[builder(setter(into))] + pub label: String, + #[builder(setter(into))] + pub value: String, + #[builder(default = Style::default())] + pub label_style: Style, + #[builder(default = Style::default().reversed())] + pub selected_style: Style, +} + +#[derive(Default)] +pub(crate) struct PermissionSelectorState { + selected_option: usize, + tx: Option<mpsc::Sender<AiTuiEvent>>, +} + +#[props] +pub(crate) struct Select { + pub options: Vec<SelectOption>, + pub on_select: OnSelectFn, +} + +#[component(props = Select, state = PermissionSelectorState)] +pub(crate) fn permission_selector( + props: &Select, + state: &PermissionSelectorState, + hooks: &mut Hooks<Select, PermissionSelectorState>, +) -> Elements { + hooks.use_focusable(true); + hooks.use_autofocus(); + + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| { + state.tx = tx.cloned(); + }); + + hooks.use_event(move |event, props, state| { + if !event.is_key_press() { + return EventResult::Ignored; + } + + if let crossterm::event::Event::Key(key) = event { + if key.kind != crossterm::event::KeyEventKind::Press { + return EventResult::Ignored; + } + + match key.code { + KeyCode::Up => { + state.selected_option = + (state.selected_option + props.options.len() - 1) % props.options.len(); + return EventResult::Consumed; + } + KeyCode::Down => { + state.selected_option = (state.selected_option + 1) % props.options.len(); + return EventResult::Consumed; + } + KeyCode::Enter => { + let option = &props.options[state.selected_option]; + if let Some(event) = (props.on_select)(option) + && let Some(ref tx) = state.tx + { + let _ = tx.send(event); + } + return EventResult::Consumed; + } + _ => {} + } + } + + EventResult::Ignored + }); + + element!( + View { + #(for (index, option) in props.options.iter().enumerate() { + Text { Span(text: &option.label, style: if index == state.selected_option { + option.selected_style + } else { + option.label_style + }) } + }) + } + ) +} |
