aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/components
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-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.rs140
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs229
-rw-r--r--crates/atuin-ai/src/tui/components/markdown.rs213
-rw-r--r--crates/atuin-ai/src/tui/components/mod.rs3
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;