diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-26 19:19:47 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 02:19:47 +0000 |
| commit | b649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch) | |
| tree | ca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/components | |
| parent | fix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff) | |
| download | atuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip | |
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with
[eye-declare](https://github.com/BinaryMuse/eye-declare), and updates
the TUI to feel more terminal-native: output appears inline and persists
in scrollback, so you can scroll up and look at previous conversations
for reference.
The "review" state — which used to exist between the LLM generating a
response and the user either executing or following up — has been
removed; just start typing to follow up with the LLM, or press `enter`
at the empty input box to execute the suggested command.
<img width="1203" height="633" alt="image"
src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94"
/>
Diffstat (limited to 'crates/atuin-ai/src/tui/components')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 140 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 229 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/markdown.rs | 213 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 3 |
4 files changed, 585 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs new file mode 100644 index 00000000..680b93ed --- /dev/null +++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs @@ -0,0 +1,140 @@ +//! Top-level AtuinAi component that translates key events into AiTuiEvents. +//! +//! This component wraps the entire view and handles key events that bubble up +//! from child components (or aren't consumed by them). It maps raw key events +//! to semantic `AiTuiEvent` variants based on the current `AppMode`. + +use std::sync::mpsc; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use eye_declare::{Component, EventResult, Hooks, impl_slot_children}; + +use crate::tui::events::AiTuiEvent; +use crate::tui::state::AppMode; + +/// Top-level wrapper component for the AI TUI. +/// +/// Props carry the current mode so `handle_event` can translate keys +/// into the right `AiTuiEvent`. Children are rendered via slot children. +pub struct AtuinAi { + pub mode: AppMode, + pub has_command: bool, + pub is_input_blank: bool, + pub pending_confirmation: bool, +} + +impl Default for AtuinAi { + fn default() -> Self { + Self { + mode: AppMode::Input, + has_command: false, + is_input_blank: false, + pending_confirmation: false, + } + } +} + +impl_slot_children!(AtuinAi); + +#[derive(Default)] +pub struct AtuinAiState { + tx: Option<mpsc::Sender<AiTuiEvent>>, +} + +impl Component for AtuinAi { + type State = AtuinAiState; + + fn initial_state(&self) -> Option<Self::State> { + Some(AtuinAiState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) { + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| { + state.tx = tx.cloned(); + }); + } + + fn render( + &self, + _area: ratatui::layout::Rect, + _buf: &mut ratatui::buffer::Buffer, + _state: &Self::State, + ) { + // Rendering is handled by slot children + } + + fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 { + 0 + } + + fn handle_event(&self, event: &Event, state: &mut Self::State) -> EventResult { + let Event::Key(KeyEvent { + code, + kind: KeyEventKind::Press, + modifiers, + .. + }) = event + else { + return EventResult::Ignored; + }; + + let Some(ref tx) = state.tx else { + return EventResult::Ignored; + }; + + // Ctrl+C always exits + if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') { + let _ = tx.send(AiTuiEvent::Exit); + return EventResult::Consumed; + } + + match self.mode { + AppMode::Input => match code { + KeyCode::Esc => { + if self.pending_confirmation { + let _ = tx.send(AiTuiEvent::CancelConfirmation); + return EventResult::Consumed; + } + + let _ = tx.send(AiTuiEvent::Exit); + EventResult::Consumed + } + KeyCode::Tab => { + if self.has_command && self.is_input_blank { + let _ = tx.send(AiTuiEvent::InsertCommand); + return EventResult::Consumed; + } + + EventResult::Ignored + } + KeyCode::Enter => { + if self.has_command && self.is_input_blank { + let _ = tx.send(AiTuiEvent::ExecuteCommand); + return EventResult::Consumed; + } + + EventResult::Ignored + } + _ => EventResult::Ignored, + }, + AppMode::Generating | AppMode::Streaming => match code { + KeyCode::Esc => { + let _ = tx.send(AiTuiEvent::CancelGeneration); + EventResult::Consumed + } + _ => EventResult::Ignored, + }, + AppMode::Error => match code { + KeyCode::Esc => { + let _ = tx.send(AiTuiEvent::Exit); + EventResult::Consumed + } + KeyCode::Enter | KeyCode::Char('r') => { + let _ = tx.send(AiTuiEvent::Retry); + EventResult::Consumed + } + _ => EventResult::Ignored, + }, + } + } +} diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs new file mode 100644 index 00000000..fd8132f4 --- /dev/null +++ b/crates/atuin-ai/src/tui/components/input_box.rs @@ -0,0 +1,229 @@ +//! Bordered input box component for the AI TUI. +//! +//! Wraps tui-textarea's TextArea, which handles rendering, wrapping, cursor +//! positioning, and height measurement natively. The component configures the +//! TextArea's block (border + titles) and forwards events to it. +//! +//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel. + +use std::sync::{Mutex, mpsc}; + +use crossterm::event::KeyModifiers; +use eye_declare::{Component, EventResult, Hooks}; +use ratatui::widgets::{Block, Borders, Padding}; +use ratatui_core::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::Line, + widgets::Widget, +}; +use tui_textarea::TextArea; + +use crate::tui::events::AiTuiEvent; + +/// A bordered text input box backed by tui-textarea. +/// +/// Props configure the chrome (title, footer). The TextArea itself lives +/// in the component's State so it owns cursor, wrapping, and rendering. +#[derive(Default)] +pub struct InputBox { + /// Title shown in top-left border + pub title: String, + /// Right-side label in top border + pub title_right: String, + /// Footer text shown in bottom border (keybinding hints) + pub footer: String, + /// Whether the input is currently active (shows cursor, accepts input) + pub active: bool, +} + +pub struct InputBoxState { + textarea: Mutex<TextArea<'static>>, + tx: Option<mpsc::Sender<AiTuiEvent>>, +} + +impl Default for InputBoxState { + fn default() -> Self { + let mut textarea = TextArea::default(); + textarea.set_cursor_line_style(ratatui::style::Style::default()); + textarea.set_wrap_mode(tui_textarea::WrapMode::Word); + textarea.set_placeholder_text("Type a message..."); + textarea.set_placeholder_style( + ratatui::style::Style::default() + .fg(ratatui::style::Color::DarkGray) + .add_modifier(ratatui::style::Modifier::ITALIC), + ); + Self { + textarea: Mutex::new(textarea), + tx: None, + } + } +} + +impl InputBox { + /// Build the ratatui Block with current titles/footer. + fn make_block(&self) -> Block<'_> { + let border_style = Style::default().fg(Color::DarkGray); + let title_style = Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD); + + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + if !self.title.is_empty() { + block = block + .title_top(Line::styled(format!(" {} ", self.title), title_style).left_aligned()); + } + if !self.title_right.is_empty() { + block = block.title_top( + Line::styled(format!(" {} ", self.title_right), border_style).right_aligned(), + ); + } + if !self.footer.is_empty() { + block = block.title_bottom( + Line::styled(format!(" {} ", self.footer), border_style).right_aligned(), + ); + } + + block + } +} + +impl Component for InputBox { + type State = InputBoxState; + + fn initial_state(&self) -> Option<InputBoxState> { + Some(InputBoxState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) { + if self.active { + hooks.use_autofocus(); + } + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| { + state.tx = tx.cloned(); + }); + } + + fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { + if area.height < 3 || area.width < 4 { + return; + } + // Configure the block on each render so titles/footer stay current. + // Note: set_block takes ownership, but the block is cheap to rebuild. + // We can't call set_block here since we only have &self/&state, + // so we render block + textarea separately. + let block = self.make_block(); + let inner = block.inner(area); + block.render(area, buf); + + let mut textarea = state.textarea.lock().unwrap(); + if self.active { + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + textarea.set_placeholder_text("Type a message..."); + } else { + textarea.set_cursor_style(Style::default()); + textarea.set_placeholder_text(""); + } + + // Render textarea into the inner area + textarea.render(inner, buf); + } + + fn desired_height(&self, width: u16, state: &Self::State) -> u16 { + if width < 4 { + return 3; + } + // TextArea handles scrolling internally if content overflows. + let block = self.make_block(); + let inner = block.inner(Rect::new(0, 0, width, u16::MAX)); + let chrome = (u16::MAX).saturating_sub(inner.height); + let content = state.textarea.lock().unwrap().measure(width - 4); + chrome + content.preferred_rows + } + + fn is_focusable(&self, _state: &Self::State) -> bool { + self.active + } + + fn handle_event( + &self, + event: &crossterm::event::Event, + state: &mut Self::State, + ) -> EventResult { + if !self.active { + return EventResult::Ignored; + } + + if let crossterm::event::Event::Paste(text) = event { + let mut textarea = state.textarea.lock().unwrap(); + textarea.insert_str(text); + return EventResult::Consumed; + } + + if let crossterm::event::Event::Key(key) = event { + if key.kind != crossterm::event::KeyEventKind::Press { + return EventResult::Ignored; + } + + // Let Ctrl+C bubble up to AtuinAi for exit handling + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == crossterm::event::KeyCode::Char('c') + { + return EventResult::Ignored; + } + + let mut textarea = state.textarea.lock().unwrap(); + + match key.code { + crossterm::event::KeyCode::Char('j') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + textarea.insert_newline(); + return EventResult::Consumed; + } + crossterm::event::KeyCode::Enter => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + textarea.insert_newline(); + return EventResult::Consumed; + } else { + let text = textarea.lines().join("\n"); + textarea.clear(); + + if text.trim().is_empty() { + return EventResult::Ignored; + } + + if let Some(ref tx) = state.tx { + let _ = tx.send(AiTuiEvent::SubmitInput(text)); + } + return EventResult::Consumed; + } + } + crossterm::event::KeyCode::Tab => { + return EventResult::Ignored; + } + // Esc: bubble up to app + crossterm::event::KeyCode::Esc => { + return EventResult::Ignored; + } + _ => {} + } + + // All other keys: forward to textarea. + // tui-textarea can convert crossterm events itself. + textarea.input(*key); + + if let Some(ref tx) = state.tx { + let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n"))); + } + return EventResult::Consumed; + } + + EventResult::Ignored + } +} diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs new file mode 100644 index 00000000..e1551a7f --- /dev/null +++ b/crates/atuin-ai/src/tui/components/markdown.rs @@ -0,0 +1,213 @@ +//! Markdown rendering component using pulldown-cmark. +//! +//! More robust than eye-declare's built-in Markdown component: +//! uses a proper CommonMark parser rather than line-by-line regex. + +use eye_declare::Component; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui_core::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::Widget, +}; +use ratatui_widgets::paragraph::{Paragraph, Wrap}; + +/// A markdown rendering component backed by pulldown-cmark. +#[derive(Default)] +pub 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 base: Style, + pub code_inline: Style, + pub code_block: Style, + pub bold: Style, + pub italic: Style, + pub heading: Style, +} + +impl MarkdownStyles { + pub fn new() -> Self { + let base = Style::default(); + Self { + base, + code_inline: Style::default().fg(Color::Yellow), + code_block: Style::default().fg(Color::Green), + bold: base.add_modifier(Modifier::BOLD), + italic: base.add_modifier(Modifier::ITALIC), + heading: Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + } + } +} + +impl Default for MarkdownStyles { + fn default() -> Self { + Self::new() + } +} + +impl Component for Markdown { + type State = MarkdownStyles; + + fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { + if self.source.is_empty() || area.width == 0 || area.height == 0 { + return; + } + let text = parse_markdown(&self.source, state); + Paragraph::new(text) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn desired_height(&self, width: u16, state: &Self::State) -> u16 { + if self.source.is_empty() || width == 0 { + return 0; + } + let text = parse_markdown(&self.source, state); + Paragraph::new(text) + .wrap(Wrap { trim: false }) + .line_count(width) as u16 + } + + fn initial_state(&self) -> Option<MarkdownStyles> { + Some(MarkdownStyles::new()) + } +} + +/// Parse markdown source into styled ratatui Text using pulldown-cmark. +fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'static> { + let parser = Parser::new(source); + let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()]; + let mut current_line = 0; + + let mut style_stack: Vec<Style> = vec![styles.base]; + let mut in_code_block = 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); + 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); + style_stack.push(italic); + } + Event::End(TagEnd::Emphasis) => { + style_stack.pop(); + } + Event::Start(Tag::CodeBlock(_)) => { + in_code_block = true; + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::Code(code) => { + lines[current_line].push(Span::styled(format!("{}", code), styles.code_inline)); + } + Event::Text(text) => { + let current_style = if in_code_block { + styles.code_block + } else { + style_stack.last().copied().unwrap_or(styles.base) + }; + let prefix = if in_code_block { " " } else { "" }; + let parts: Vec<&str> = text.split('\n').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + current_line += 1; + lines.push(Vec::new()); + } + if !part.is_empty() { + lines[current_line] + .push(Span::styled(format!("{}{}", prefix, part), current_style)); + } + } + } + Event::SoftBreak => { + let current_style = style_stack.last().copied().unwrap_or(styles.base); + lines[current_line].push(Span::styled(" ", current_style)); + } + Event::HardBreak => { + current_line += 1; + 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()); + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::Paragraph) => {} + Event::Start(Tag::Heading { .. }) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + current_line += 1; + lines.push(Vec::new()); + } + style_stack.push(styles.heading); + } + Event::End(TagEnd::Heading(_)) => { + style_stack.pop(); + } + Event::Start(Tag::Item) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray))); + } + Event::End(TagEnd::Item) => {} + Event::Start(Tag::List(_)) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::List(_)) => {} + _ => {} + } + } + + let text_lines: Vec<Line<'static>> = lines.into_iter().map(Line::from).collect(); + Text::from(text_lines) +} diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs new file mode 100644 index 00000000..2f684f5f --- /dev/null +++ b/crates/atuin-ai/src/tui/components/mod.rs @@ -0,0 +1,3 @@ +pub mod atuin_ai; +pub mod input_box; +pub mod markdown; |
