aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui/components.rs')
-rw-r--r--crates/atuin-ai/src/tui/components.rs510
1 files changed, 510 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/components.rs b/crates/atuin-ai/src/tui/components.rs
new file mode 100644
index 00000000..50abd8c1
--- /dev/null
+++ b/crates/atuin-ai/src/tui/components.rs
@@ -0,0 +1,510 @@
+//! 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)
+}