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 '')
-rw-r--r--crates/atuin-ai/src/tui/components.rs510
-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
5 files changed, 585 insertions, 510 deletions
diff --git a/crates/atuin-ai/src/tui/components.rs b/crates/atuin-ai/src/tui/components.rs
deleted file mode 100644
index 50abd8c1..00000000
--- a/crates/atuin-ai/src/tui/components.rs
+++ /dev/null
@@ -1,510 +0,0 @@
-//! Leaf components for each content type and factory functions for building
-//! the component tree from the view model.
-
-use atuin_client::theme::{Meaning, Theme};
-use ratatui::{
- Frame,
- backend::FromCrossterm,
- layout::Rect,
- style::{Modifier, Style},
- text::{Line, Span},
- widgets::{Paragraph, Wrap},
-};
-
-use super::component::{Component, RenderContext, Separator, Spacer, SymbolRow, VStack};
-use super::spinner::active_frame;
-use super::view_model::{Block, Content, WarningKind};
-
-// ---------------------------------------------------------------------------
-// Text measurement utilities
-// ---------------------------------------------------------------------------
-
-/// Count lines when text is wrapped at given width.
-/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation.
-pub(crate) fn line_count_wrapped(text: &str, width: usize) -> u16 {
- if width == 0 {
- return 1;
- }
- let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
- paragraph.line_count(width as u16).max(1) as u16
-}
-
-/// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word).
-/// Words won't be broken mid-word, so this may produce more lines than character wrapping.
-/// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space.
-pub(crate) fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) {
- if width == 0 || text.is_empty() {
- return (1, 0);
- }
-
- let mut line_count = 0u16;
- let mut current_line_width = 0usize;
-
- for line in text.lines() {
- if line.is_empty() {
- line_count += 1;
- current_line_width = 0;
- continue;
- }
-
- let mut line_started = false;
-
- for word in line.split_whitespace() {
- let word_width = unicode_width::UnicodeWidthStr::width(word);
-
- if !line_started {
- if word_width > width {
- line_count += word_width.div_ceil(width) as u16;
- current_line_width = word_width % width;
- if current_line_width == 0 {
- current_line_width = 0;
- line_started = false;
- } else {
- line_started = true;
- }
- } else {
- current_line_width = word_width;
- line_started = true;
- }
- } else {
- let needed = current_line_width + 1 + word_width;
- if needed > width {
- line_count += 1;
- if word_width > width {
- line_count += word_width.div_ceil(width) as u16;
- current_line_width = word_width % width;
- if current_line_width == 0 {
- line_started = false;
- }
- } else {
- current_line_width = word_width;
- }
- } else {
- current_line_width = needed;
- }
- }
- }
-
- if line_started {
- line_count += 1;
- }
- }
-
- if line_count == 0 {
- line_count = 1;
- current_line_width = 0;
- }
-
- (line_count, current_line_width)
-}
-
-// ---------------------------------------------------------------------------
-// Inline markdown formatting
-// ---------------------------------------------------------------------------
-
-/// Parse inline markdown formatting (**bold** and `code`) into styled spans.
-/// Preserves all other text — list prefixes, indentation, and line structure
-/// are left exactly as-is.
-fn style_inline_markdown(text: &str, theme: &Theme) -> Vec<Line<'static>> {
- let base_style = Style::from_crossterm(theme.as_style(Meaning::Base));
- let code_style = Style::from_crossterm(theme.as_style(Meaning::Guidance));
- let bold_style = base_style.add_modifier(Modifier::BOLD);
-
- text.lines()
- .map(|line| {
- Line::from(parse_inline_formatting(
- line, base_style, bold_style, code_style,
- ))
- })
- .collect()
-}
-
-/// Parse a single line for `code` and **bold** markers, returning styled spans.
-fn parse_inline_formatting(
- line: &str,
- base: Style,
- bold: Style,
- code: Style,
-) -> Vec<Span<'static>> {
- let mut spans = Vec::new();
- let mut current = String::new();
- let mut chars = line.chars().peekable();
-
- while let Some(ch) = chars.next() {
- if ch == '`' {
- // Flush accumulated plain text
- if !current.is_empty() {
- spans.push(Span::styled(std::mem::take(&mut current), base));
- }
- // Collect until closing backtick
- let mut code_text = String::new();
- let mut closed = false;
- for next in chars.by_ref() {
- if next == '`' {
- closed = true;
- break;
- }
- code_text.push(next);
- }
- if closed {
- spans.push(Span::styled(code_text, code));
- } else {
- // Unclosed backtick — render as-is
- current.push('`');
- current.push_str(&code_text);
- }
- } else if ch == '*' && chars.peek() == Some(&'*') {
- chars.next(); // consume second *
- // Flush accumulated plain text
- if !current.is_empty() {
- spans.push(Span::styled(std::mem::take(&mut current), base));
- }
- // Collect until closing **
- let mut bold_text = String::new();
- let mut closed = false;
- while let Some(next) = chars.next() {
- if next == '*' && chars.peek() == Some(&'*') {
- chars.next();
- closed = true;
- break;
- }
- bold_text.push(next);
- }
- if closed {
- spans.push(Span::styled(bold_text, bold));
- } else {
- // Unclosed ** — render as-is
- current.push_str("**");
- current.push_str(&bold_text);
- }
- } else {
- current.push(ch);
- }
- }
-
- if !current.is_empty() {
- spans.push(Span::styled(current, base));
- }
-
- spans
-}
-
-// ---------------------------------------------------------------------------
-// Leaf components
-// ---------------------------------------------------------------------------
-
-/// User input display (active textarea or static text).
-pub struct InputContent {
- pub text: String,
- pub active: bool,
-}
-
-impl Component for InputContent {
- fn height(&self, width: u16) -> u16 {
- let w = width as usize;
- if self.active {
- let (lines, last_width) = word_wrap_line_count_with_last_width(&self.text, w);
- if last_width >= w {
- lines.saturating_add(1)
- } else {
- lines
- }
- } else {
- line_count_wrapped(&self.text, w)
- }
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- if self.active {
- if let Some(textarea) = ctx.textarea {
- frame.render_widget(textarea, area);
- }
- } else {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
- frame.render_widget(
- Paragraph::new(self.text.as_str())
- .style(style)
- .wrap(Wrap { trim: false }),
- area,
- );
- }
- }
-}
-
-/// Command suggestion ($ prefix).
-pub struct CommandContent {
- pub text: String,
- pub faded: bool,
-}
-
-impl Component for CommandContent {
- fn height(&self, width: u16) -> u16 {
- line_count_wrapped(&self.text, width as usize)
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let mut style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
- if self.faded {
- style = style.add_modifier(Modifier::DIM);
- }
- frame.render_widget(
- Paragraph::new(self.text.as_str())
- .style(style)
- .wrap(Wrap { trim: false }),
- area,
- );
- }
-}
-
-/// Markdown text content (indented, no symbol).
-pub struct TextContent {
- pub markdown: String,
-}
-
-impl Component for TextContent {
- fn height(&self, width: u16) -> u16 {
- // Height uses raw text — slightly overestimates since markdown syntax
- // characters (**, `) are stripped in rendering, but this is harmless
- // (allocates equal or more space than needed, never less).
- line_count_wrapped(&self.markdown, width as usize)
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let lines = style_inline_markdown(&self.markdown, ctx.theme);
- let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
- frame.render_widget(paragraph, area);
- }
-}
-
-/// Error message (! prefix).
-pub struct ErrorContent {
- pub message: String,
-}
-
-impl Component for ErrorContent {
- fn height(&self, width: u16) -> u16 {
- line_count_wrapped(&self.message, width as usize)
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
- frame.render_widget(
- Paragraph::new(self.message.as_str())
- .style(style)
- .wrap(Wrap { trim: false }),
- area,
- );
- }
-}
-
-/// Warning for dangerous or low-confidence commands.
-pub struct WarningContent {
- pub kind: WarningKind,
- pub text: String,
- pub pending_confirm: bool,
-}
-
-impl Component for WarningContent {
- fn height(&self, width: u16) -> u16 {
- let display_text = if self.pending_confirm {
- "Press Enter again to run this dangerous command"
- } else {
- self.text.as_str()
- };
- line_count_wrapped(display_text, width as usize)
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
- let display_text = if self.pending_confirm {
- "Press Enter again to run this dangerous command"
- } else {
- self.text.as_str()
- };
- frame.render_widget(
- Paragraph::new(display_text)
- .style(style)
- .wrap(Wrap { trim: false }),
- area,
- );
- }
-}
-
-/// Animated spinner with status text.
-pub struct SpinnerContent {
- pub status_text: String,
-}
-
-impl Component for SpinnerContent {
- fn height(&self, _width: u16) -> u16 {
- 1
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));
- frame.render_widget(Paragraph::new(self.status_text.as_str()).style(style), area);
- }
-}
-
-/// Tool call progress (in-flight spinner or completed checkmark).
-pub struct ToolStatusContent {
- pub completed_count: usize,
- pub current_label: Option<String>,
- pub frame: usize,
-}
-
-impl Component for ToolStatusContent {
- fn height(&self, _width: u16) -> u16 {
- 1
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));
- let text = if let Some(ref label) = self.current_label {
- if self.completed_count > 0 {
- format!(
- "{} (used {} tool{})",
- label,
- self.completed_count,
- if self.completed_count == 1 { "" } else { "s" }
- )
- } else {
- label.clone()
- }
- } else {
- format!(
- "Used {} tool{}",
- self.completed_count,
- if self.completed_count == 1 { "" } else { "s" }
- )
- };
- frame.render_widget(Paragraph::new(text).style(style), area);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Factory functions
-// ---------------------------------------------------------------------------
-
-/// Convert a view model `Content` item into a `SymbolRow`-wrapped component.
-fn content_to_component(content: &Content) -> Box<dyn Component> {
- match content {
- Content::Input { text, active, .. } => Box::new(SymbolRow {
- symbol: ">".to_string(),
- symbol_meaning: Meaning::Guidance,
- inner: Box::new(InputContent {
- text: text.clone(),
- active: *active,
- }),
- }),
-
- Content::Command { text, faded } => Box::new(SymbolRow {
- symbol: "$".to_string(),
- symbol_meaning: Meaning::Important,
- inner: Box::new(CommandContent {
- text: text.clone(),
- faded: *faded,
- }),
- }),
-
- Content::Text { markdown } => Box::new(SymbolRow {
- symbol: " ".to_string(),
- symbol_meaning: Meaning::Base,
- inner: Box::new(TextContent {
- markdown: markdown.clone(),
- }),
- }),
-
- Content::Error { message } => Box::new(SymbolRow {
- symbol: "!".to_string(),
- symbol_meaning: Meaning::AlertError,
- inner: Box::new(ErrorContent {
- message: message.clone(),
- }),
- }),
-
- Content::Warning {
- kind,
- text,
- pending_confirm,
- } => {
- let (symbol, meaning) = match kind {
- WarningKind::Danger => ("!", Meaning::AlertError),
- WarningKind::LowConfidence => ("?", Meaning::AlertWarn),
- };
- Box::new(SymbolRow {
- symbol: symbol.to_string(),
- symbol_meaning: meaning,
- inner: Box::new(WarningContent {
- kind: *kind,
- text: text.clone(),
- pending_confirm: *pending_confirm,
- }),
- })
- }
-
- Content::Spinner { frame, status_text } => Box::new(SymbolRow {
- symbol: active_frame(*frame).to_string(),
- symbol_meaning: Meaning::Annotation,
- inner: Box::new(SpinnerContent {
- status_text: status_text.clone(),
- }),
- }),
-
- Content::ToolStatus {
- completed_count,
- current_label,
- frame,
- } => {
- let symbol = if current_label.is_some() {
- active_frame(*frame).to_string()
- } else {
- "\u{2713}".to_string() // ✓
- };
- Box::new(SymbolRow {
- symbol,
- symbol_meaning: Meaning::Annotation,
- inner: Box::new(ToolStatusContent {
- completed_count: *completed_count,
- current_label: current_label.clone(),
- frame: *frame,
- }),
- })
- }
- }
-}
-
-/// Convert a view model `Block` into a `VStack` of content components.
-fn build_block_component(block: &Block) -> Box<dyn Component> {
- let mut children: Vec<Box<dyn Component>> = Vec::new();
-
- for (idx, content) in block.content.iter().enumerate() {
- if idx > 0 {
- children.push(Box::new(Spacer(1))); // blank line between items
- }
- children.push(content_to_component(content));
- }
-
- // Trailing blank line (padding after content)
- children.push(Box::new(Spacer(1)));
-
- Box::new(VStack::new(children))
-}
-
-/// Build the full component tree from an ordered list of view model blocks.
-///
-/// The tree is a `VStack` with blocks separated by `Separator` + `Spacer` pairs.
-/// The caller sets `scroll_offset` on the returned `VStack` before rendering.
-pub fn build_component_tree(items: &[&Block], card_width: u16) -> VStack {
- let mut children: Vec<Box<dyn Component>> = Vec::new();
-
- for (idx, block) in items.iter().enumerate() {
- if idx > 0 {
- children.push(Box::new(Separator { card_width }));
- children.push(Box::new(Spacer(1))); // leading blank after separator
- }
- children.push(build_block_component(block));
- }
-
- VStack::new(children)
-}
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;