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.rs | |
| 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.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/components.rs | 510 |
1 files changed, 0 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) -} |
