aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-17 16:39:37 -0700
committerGitHub <noreply@github.com>2026-03-17 16:39:37 -0700
commit2ef3d38ab316e67ae57f24c281dfe8614f8670d6 (patch)
treeac04d9c75e291ed6558ce21aa827d5734873510c /crates
parentfeat: Report distro name with OS for distro-specific commands (#3289) (diff)
downloadatuin-2ef3d38ab316e67ae57f24c281dfe8614f8670d6.zip
chore: Replace atuin-ai rendering with component-oriented system (#3288)
Diffstat (limited to 'crates')
-rwxr-xr-xcrates/atuin-ai/replay-states.sh6
-rw-r--r--crates/atuin-ai/src/tui/component.rs186
-rw-r--r--crates/atuin-ai/src/tui/components.rs510
-rw-r--r--crates/atuin-ai/src/tui/mod.rs2
-rw-r--r--crates/atuin-ai/src/tui/render.rs515
5 files changed, 719 insertions, 500 deletions
diff --git a/crates/atuin-ai/replay-states.sh b/crates/atuin-ai/replay-states.sh
index 4f586709..791ad47e 100755
--- a/crates/atuin-ai/replay-states.sh
+++ b/crates/atuin-ai/replay-states.sh
@@ -25,7 +25,7 @@ if [[ ! -f "$STATE_FILE" ]]; then
fi
# Build once
-cargo build -p atuin-ai --quiet
+cargo build -p atuin --quiet
# Count entries
TOTAL=$(wc -l < "$STATE_FILE" | tr -d ' ')
@@ -45,7 +45,7 @@ if [[ -n "$ENTRY_FILTER" ]]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[$ENTRY/$TOTAL] $LABEL"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain
+ echo "$STATE" | cargo run -p atuin --quiet -- ai debug-render -f ansi
else
# Interactive replay
echo "Replaying $TOTAL frames from $STATE_FILE"
@@ -63,7 +63,7 @@ else
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[$CURRENT/$TOTAL] $LABEL"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- echo "$STATE" | cargo run -p atuin-ai --quiet -- debug-render -f plain
+ echo "$STATE" | cargo run -p atuin --quiet -- ai debug-render -f ansi
echo ""
echo "[Enter: next] [p: prev] [number: jump] [s: show state JSON] [q: quit]"
diff --git a/crates/atuin-ai/src/tui/component.rs b/crates/atuin-ai/src/tui/component.rs
new file mode 100644
index 00000000..ff20f195
--- /dev/null
+++ b/crates/atuin-ai/src/tui/component.rs
@@ -0,0 +1,186 @@
+//! Component-oriented rendering primitives for the TUI.
+//!
+//! Defines the `Component` trait and container types (`VStack`, `SymbolRow`, etc.)
+//! that enable declarative, composable UI layout.
+
+use atuin_client::theme::{Meaning, Theme};
+use ratatui::{
+ Frame, backend::FromCrossterm, layout::Rect, style::Style, text::Span, widgets::Paragraph,
+};
+use tui_textarea::TextArea;
+
+/// Context passed through the component tree during rendering.
+pub struct RenderContext<'a> {
+ pub theme: &'a Theme,
+ pub anchor_col: u16,
+ pub textarea: Option<&'a TextArea<'static>>,
+ /// Maximum viewport height (for scroll calculations)
+ pub max_height: u16,
+ /// When true, the viewport is a fixed rect already positioned for the card.
+ /// The card fills the entire viewport instead of positioning via anchor_col.
+ pub popup_mode: bool,
+ /// When true, blocks are rendered in reverse order so that the input field
+ /// appears at the bottom of the card (close to the prompt when the popup
+ /// is above the cursor).
+ pub render_above: bool,
+}
+
+/// A renderable component with intrinsic sizing.
+pub trait Component {
+ /// Calculate the intrinsic height at the given width.
+ fn height(&self, width: u16) -> u16;
+
+ /// Render into the given area.
+ fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext);
+}
+
+/// Vertical stack of components.
+///
+/// Children are laid out top-to-bottom with optional spacing between them.
+/// When `scroll_offset > 0`, content is scrolled so that only the visible
+/// portion is rendered.
+pub struct VStack {
+ pub children: Vec<Box<dyn Component>>,
+ pub spacing: u16,
+ pub scroll_offset: u16,
+}
+
+impl VStack {
+ pub fn new(children: Vec<Box<dyn Component>>) -> Self {
+ Self {
+ children,
+ spacing: 0,
+ scroll_offset: 0,
+ }
+ }
+}
+
+impl Component for VStack {
+ fn height(&self, width: u16) -> u16 {
+ if self.children.is_empty() {
+ return 0;
+ }
+ let content: u16 = self.children.iter().map(|c| c.height(width)).sum();
+ let gaps = (self.children.len() as u16 - 1) * self.spacing;
+ content + gaps
+ }
+
+ fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
+ if self.children.is_empty() {
+ return;
+ }
+
+ let heights: Vec<u16> = self.children.iter().map(|c| c.height(area.width)).collect();
+
+ let viewport_start = self.scroll_offset;
+ let viewport_end = self.scroll_offset + area.height;
+
+ let mut cum: u16 = 0;
+ for (i, (child, &h)) in self.children.iter().zip(heights.iter()).enumerate() {
+ let child_start = cum;
+ let child_end = cum + h;
+
+ // Render if any part of the child is within the viewport
+ if child_end > viewport_start && child_start < viewport_end {
+ let visible_start = child_start.max(viewport_start);
+ let visible_end = child_end.min(viewport_end);
+
+ let child_area = Rect {
+ x: area.x,
+ y: area.y + visible_start - viewport_start,
+ width: area.width,
+ height: visible_end - visible_start,
+ };
+
+ child.render(frame, child_area, ctx);
+ }
+
+ cum = child_end;
+ if i < self.children.len() - 1 {
+ cum += self.spacing;
+ }
+ }
+ }
+}
+
+/// Fixed-height empty space.
+pub struct Spacer(pub u16);
+
+impl Component for Spacer {
+ fn height(&self, _width: u16) -> u16 {
+ self.0
+ }
+
+ fn render(&self, _frame: &mut Frame, _area: Rect, _ctx: &RenderContext) {}
+}
+
+/// A row with a symbol in column 0 and content in columns 2+.
+///
+/// This is the horizontal layout primitive used by all content types that
+/// display a prefix symbol (>, $, !, ?, etc.) followed by text.
+pub struct SymbolRow {
+ pub symbol: String,
+ pub symbol_meaning: Meaning,
+ pub inner: Box<dyn Component>,
+}
+
+impl Component for SymbolRow {
+ fn height(&self, width: u16) -> u16 {
+ self.inner.height(width.saturating_sub(2))
+ }
+
+ fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) {
+ // Render symbol at column 0, first row only
+ let style = Style::from_crossterm(ctx.theme.as_style(self.symbol_meaning));
+ let symbol_area = Rect {
+ x: area.x,
+ y: area.y,
+ width: 1,
+ height: 1,
+ };
+ frame.render_widget(
+ Paragraph::new(self.symbol.as_str()).style(style),
+ symbol_area,
+ );
+
+ // Render inner content at column 2+
+ let content_area = Rect {
+ x: area.x.saturating_add(2),
+ y: area.y,
+ width: area.width.saturating_sub(2),
+ height: area.height,
+ };
+ self.inner.render(frame, content_area, ctx);
+ }
+}
+
+/// Horizontal separator spanning the full card width (├───┤).
+///
+/// Extends beyond its content area to overlap the card's left and right borders.
+pub struct Separator {
+ pub card_width: u16,
+}
+
+impl Component for Separator {
+ 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::Base));
+ let inner_width = self.card_width.saturating_sub(2) as usize;
+ let separator = format!(
+ "\u{251c}{}\u{2524}", // ├ ... ┤
+ "\u{2500}".repeat(inner_width) // ─
+ );
+
+ // Extend left to overlap the card border (content area is inset by border + padding)
+ let sep_area = Rect {
+ x: area.x.saturating_sub(2),
+ y: area.y,
+ width: self.card_width,
+ height: 1,
+ };
+ frame.render_widget(Paragraph::new(Span::styled(separator, style)), sep_area);
+ }
+}
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)
+}
diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs
index 03a9c007..6df3d08f 100644
--- a/crates/atuin-ai/src/tui/mod.rs
+++ b/crates/atuin-ai/src/tui/mod.rs
@@ -1,4 +1,6 @@
pub mod app;
+pub mod component;
+pub mod components;
pub mod event;
#[cfg(unix)]
pub mod popup;
diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs
index 9326b0df..9b514ccf 100644
--- a/crates/atuin-ai/src/tui/render.rs
+++ b/crates/atuin-ai/src/tui/render.rs
@@ -3,35 +3,22 @@ use pulldown_cmark::{Event, Parser, Tag, TagEnd};
use ratatui::{
Frame,
backend::FromCrossterm,
- layout::{Alignment, Constraint, Direction, Layout, Rect},
+ layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
- widgets::{Block as RatatuiBlock, Borders, Padding, Paragraph, Wrap},
+ widgets::{Block as RatatuiBlock, Borders, Padding},
};
-use tui_textarea::TextArea;
+use super::component::Component;
+pub use super::component::RenderContext;
+use super::components::build_component_tree;
use super::spinner::active_frame;
use super::state::AppState;
-use super::view_model::{Blocks, Content, WarningKind};
+use super::view_model::Blocks;
/// Fixed card width for the TUI
pub(crate) const CARD_WIDTH: u16 = 64;
-pub struct RenderContext<'a> {
- pub theme: &'a Theme,
- pub anchor_col: u16,
- pub textarea: Option<&'a TextArea<'static>>,
- /// Maximum viewport height (for scroll calculations)
- pub max_height: u16,
- /// When true, the viewport is a fixed rect already positioned for the card.
- /// The card fills the entire viewport instead of positioning via anchor_col.
- pub popup_mode: bool,
- /// When true, blocks are rendered in reverse order so that the input field
- /// appears at the bottom of the card (close to the prompt when the popup
- /// is above the cursor).
- pub render_above: bool,
-}
-
/// Calculate the height needed to render the current state.
/// Used to dynamically resize the viewport before rendering.
/// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default.
@@ -42,20 +29,13 @@ pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 {
} else {
CARD_WIDTH
};
- let content_width = usize::from(w.saturating_sub(4)).max(1);
+ let content_width = w.saturating_sub(4).max(1);
- let mut total_height = 0u16;
- for (idx, block) in view.items.iter().enumerate() {
- if idx > 0 {
- total_height = total_height.saturating_add(1); // separator
- total_height = total_height.saturating_add(1); // leading blank after separator
- }
- total_height =
- total_height.saturating_add(calculate_block_height(&block.content, content_width));
- }
+ let items: Vec<_> = view.items.iter().collect();
+ let tree = build_component_tree(&items, w);
// Add borders (2) + top padding (1), minimum 5
- total_height.saturating_add(3).max(5)
+ tree.height(content_width).saturating_add(3).max(5)
}
/// Main render function: derives view model from state, then renders it
@@ -89,7 +69,6 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) {
let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2);
(full_area, preferred_x.min(max_x), dw)
};
- let content_width = usize::from(desired_width.saturating_sub(4)).max(1);
// Build ordered items list — the active content (input/LLM response)
// should always be closest to the cursor/prompt:
@@ -102,20 +81,11 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) {
view.items.iter().collect()
};
- // Calculate height from view model
- let mut total_height = 0u16;
- for (idx, block) in items.iter().enumerate() {
- if idx > 0 {
- total_height = total_height.saturating_add(1); // separator
- total_height = total_height.saturating_add(1); // leading blank after separator
- }
- total_height =
- total_height.saturating_add(calculate_block_height(&block.content, content_width));
- }
+ // Build component tree from view model
+ let mut tree = build_component_tree(&items, desired_width);
+ let content_width = desired_width.saturating_sub(4).max(1);
- let desired_height = total_height
- .saturating_add(3) // borders (2) + top padding (1), no bottom padding
- .max(5);
+ let desired_height = tree.height(content_width).saturating_add(3).max(5);
// Cap card height at viewport height to prevent overflow
let actual_height = desired_height.min(area.height);
@@ -124,7 +94,7 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) {
// When render_above=false (popup below cursor), items are reversed so the active
// content (input/spinner) is at the top — scroll_offset stays 0 to show the top.
// Otherwise, scroll to show the bottom where the active content lives.
- let scroll_offset = if ctx.popup_mode && !ctx.render_above {
+ tree.scroll_offset = if ctx.popup_mode && !ctx.render_above {
0
} else {
desired_height.saturating_sub(actual_height)
@@ -164,460 +134,11 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) {
let inner_area = outer_block.inner(card);
frame.render_widget(outer_block, card);
- // Render blocks (with scroll offset for overflowing content)
- render_blocks_content(frame, &items, ctx, inner_area, card.width, scroll_offset);
-}
-
-fn render_blocks_content(
- frame: &mut Frame,
- items: &[&super::view_model::Block],
- ctx: &RenderContext,
- area: Rect,
- card_width: u16,
- scroll_offset: u16,
-) {
- let content_width = usize::from(area.width).max(1);
-
- // Build layout constraints for full content
- let mut constraints = Vec::new();
- let mut block_heights = Vec::new();
- for (idx, block) in items.iter().enumerate() {
- if idx > 0 {
- constraints.push(Constraint::Length(1)); // separator
- constraints.push(Constraint::Length(1)); // leading blank after separator
- block_heights.push(1);
- block_heights.push(1);
- }
- let height = calculate_block_height(&block.content, content_width);
- constraints.push(Constraint::Length(height));
- block_heights.push(height);
- }
-
- if constraints.is_empty() {
- return;
- }
-
- // Calculate cumulative heights to find which blocks are visible after scrolling
- let mut cumulative: Vec<u16> = Vec::with_capacity(block_heights.len() + 1);
- cumulative.push(0);
- for h in &block_heights {
- cumulative.push(cumulative.last().unwrap() + h);
- }
-
- // Render each chunk, offsetting by scroll_offset and clipping to visible area
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints(constraints)
- .split(area);
-
- let mut chunk_idx = 0;
- for (idx, block) in items.iter().enumerate() {
- if idx > 0 {
- // Check if separator is visible (its position minus scroll_offset)
- let sep_start = cumulative[chunk_idx];
- if sep_start >= scroll_offset && sep_start < scroll_offset + area.height {
- let adjusted_chunk = Rect {
- y: area.y + sep_start - scroll_offset,
- ..chunks[chunk_idx]
- };
- render_separator(frame, adjusted_chunk, ctx, card_width);
- }
- chunk_idx += 1;
- chunk_idx += 1; // skip leading blank
- }
-
- // Check if this block is at least partially visible
- let block_start = cumulative[chunk_idx];
- let block_end = cumulative[chunk_idx + 1];
-
- // Block is visible if it starts before viewport end and ends after viewport start
- if block_start < scroll_offset + area.height && block_end > scroll_offset {
- // Calculate visible portion
- let visible_start = block_start.max(scroll_offset);
- let visible_end = block_end.min(scroll_offset + area.height);
-
- let adjusted_chunk = Rect {
- x: area.x,
- y: area.y + visible_start - scroll_offset,
- width: area.width,
- height: visible_end - visible_start,
- };
-
- render_block_content(frame, &block.content, adjusted_chunk, ctx);
- }
-
- chunk_idx += 1;
- }
-}
-
-/// Render all content items in a block
-fn render_block_content(frame: &mut Frame, content: &[Content], area: Rect, ctx: &RenderContext) {
- if content.is_empty() {
- return;
- }
-
- let content_width = usize::from(area.width).max(1);
-
- // Build layout constraints for each content item WITH spacing between items
- let mut constraints = Vec::new();
- for (idx, c) in content.iter().enumerate() {
- if idx > 0 {
- constraints.push(Constraint::Length(1)); // blank line between items
- }
- constraints.push(Constraint::Length(calculate_single_content_height(
- c,
- content_width,
- )));
- }
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints(constraints)
- .split(area);
-
- let mut chunk_idx = 0;
- for (idx, item) in content.iter().enumerate() {
- if idx > 0 {
- chunk_idx += 1; // skip the blank line chunk
- }
- render_single_content(frame, item, chunks[chunk_idx], ctx);
- chunk_idx += 1;
- }
-}
-
-/// Render a single content item using ratatui's native wrapping.
-/// Symbol is rendered at column 0, text wraps in columns 2+ (offset area).
-fn render_single_content(frame: &mut Frame, content: &Content, area: Rect, ctx: &RenderContext) {
- // Helper to create offset text area (2 chars for symbol column)
- let text_area = Rect {
- x: area.x.saturating_add(2),
- y: area.y,
- width: area.width.saturating_sub(2),
- height: area.height,
- };
-
- match content {
- Content::Input { text, active, .. } => {
- let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Guidance));
- let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
-
- // Render ">" symbol at column 0
- render_symbol(frame, ">", symbol_style, area);
-
- if *active {
- // Active input: render TextArea widget (handles cursor display)
- if let Some(textarea) = ctx.textarea {
- frame.render_widget(textarea, text_area);
- }
- } else {
- // Inactive input: render as plain paragraph
- let paragraph = Paragraph::new(text.as_str())
- .style(text_style)
- .wrap(Wrap { trim: false });
- frame.render_widget(paragraph, text_area);
- }
- }
-
- Content::Command { text, faded } => {
- let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Important));
- let mut text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
- if *faded {
- text_style = text_style.add_modifier(Modifier::DIM);
- }
-
- render_symbol(frame, "$", symbol_style, area);
-
- let paragraph = Paragraph::new(text.as_str())
- .style(text_style)
- .wrap(Wrap { trim: false });
- frame.render_widget(paragraph, text_area);
- }
-
- Content::Text { markdown } => {
- // No symbol, just indent - render directly in offset area
- let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
-
- let paragraph = Paragraph::new(markdown.as_str())
- .style(text_style)
- .wrap(Wrap { trim: false });
- frame.render_widget(paragraph, text_area);
- }
-
- Content::Error { message } => {
- let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::AlertError));
- let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
-
- render_symbol(frame, "!", symbol_style, area);
-
- let paragraph = Paragraph::new(message.as_str())
- .style(text_style)
- .wrap(Wrap { trim: false });
- frame.render_widget(paragraph, text_area);
- }
-
- Content::Warning {
- kind,
- text,
- pending_confirm,
- } => {
- let (symbol, meaning) = match kind {
- WarningKind::Danger => ("!", Meaning::AlertError),
- WarningKind::LowConfidence => ("?", Meaning::AlertWarn),
- };
- let symbol_style = Style::from_crossterm(ctx.theme.as_style(meaning));
- let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base));
-
- let display_text = if *pending_confirm {
- "Press Enter again to run this dangerous command"
- } else {
- text.as_str()
- };
-
- render_symbol(frame, symbol, symbol_style, area);
-
- let paragraph = Paragraph::new(display_text)
- .style(text_style)
- .wrap(Wrap { trim: false });
- frame.render_widget(paragraph, text_area);
- }
-
- Content::Spinner {
- frame: spinner_frame,
- status_text,
- } => {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));
- let symbol = active_frame(*spinner_frame);
-
- render_symbol(frame, symbol, style, area);
-
- let paragraph = Paragraph::new(status_text.as_str()).style(style);
- frame.render_widget(paragraph, text_area);
- }
-
- Content::ToolStatus {
- completed_count,
- current_label,
- frame: spinner_frame,
- } => {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation));
-
- let (symbol, text) = if let Some(label) = current_label {
- let spinner = active_frame(*spinner_frame);
- let text = if *completed_count > 0 {
- format!(
- "{} (used {} tool{})",
- label,
- completed_count,
- if *completed_count == 1 { "" } else { "s" }
- )
- } else {
- label.clone()
- };
- (spinner, text)
- } else {
- (
- "\u{2713}",
- format!(
- "Used {} tool{}",
- completed_count,
- if *completed_count == 1 { "" } else { "s" }
- ),
- )
- };
-
- render_symbol(frame, symbol, style, area);
-
- let paragraph = Paragraph::new(text).style(style);
- frame.render_widget(paragraph, text_area);
- }
- }
-}
-
-/// Render a single-character symbol at the start of an area
-fn render_symbol(frame: &mut Frame, symbol: &str, style: Style, area: Rect) {
- let symbol_area = Rect {
- x: area.x,
- y: area.y,
- width: 1,
- height: 1,
- };
- frame.render_widget(Paragraph::new(symbol).style(style), symbol_area);
-}
-
-fn render_separator(frame: &mut Frame, area: Rect, ctx: &RenderContext, card_width: u16) {
- let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Muted));
-
- // Build separator: ├ + ─ repeated + ┤ spanning the full card width
- // -2 for the ├ and ┤ characters themselves
- let inner_width = card_width.saturating_sub(2) as usize;
- let separator = format!(
- "\u{251c}{}\u{2524}", // ├ ... ┤
- "\u{2500}".repeat(inner_width) // ─
- );
-
- let paragraph = Paragraph::new(Span::styled(separator, style));
-
- // Render at x offset to overlap the border (area is inside padding, border is 2 chars left)
- let sep_area = Rect {
- x: area.x.saturating_sub(2), // move left to overlap left border
- y: area.y,
- width: card_width,
- height: 1,
- };
- frame.render_widget(paragraph, sep_area);
-}
-
-/// Calculate total height for all content items in a block
-fn calculate_block_height(content: &[Content], width: usize) -> u16 {
- let content_height: u16 = content
- .iter()
- .map(|c| calculate_single_content_height(c, width))
- .sum();
-
- // Add spacing between items (n-1 blank lines for n items)
- let spacing = if content.len() > 1 {
- (content.len() - 1) as u16
- } else {
- 0
- };
-
- // Add 1 for trailing blank line (padding after content)
- content_height.saturating_add(spacing).saturating_add(1)
-}
-
-/// Calculate height for a single content item.
-/// Uses ratatui's Paragraph::line_count for consistency with rendering.
-fn calculate_single_content_height(content: &Content, width: usize) -> u16 {
- // Text area is offset by 2 for symbol column
- let text_width = width.saturating_sub(2);
-
- match content {
- // Input uses word wrapping (WrapMode::Word) in TextArea, which can produce
- // more lines than character wrapping since it won't break words mid-word
- Content::Input { text, active, .. } => {
- if *active {
- // For active input, use word-wrap line counting to match TextArea behavior
- let (lines, last_line_width) =
- word_wrap_line_count_with_last_width(text, text_width);
- // Only add extra line for cursor if the last line is full
- if last_line_width >= text_width {
- lines.saturating_add(1)
- } else {
- lines
- }
- } else {
- line_count_wrapped(text, text_width)
- }
- }
- Content::Command { text, .. } => line_count_wrapped(text, text_width),
- Content::Text { markdown } => line_count_wrapped(markdown, text_width),
- Content::Error { message } => line_count_wrapped(message, text_width),
- Content::Warning {
- text,
- pending_confirm,
- ..
- } => {
- let display_text = if *pending_confirm {
- "Press Enter again to run this dangerous command"
- } else {
- text.as_str()
- };
- line_count_wrapped(display_text, text_width)
- }
- Content::Spinner { .. } => 1,
- Content::ToolStatus { .. } => 1,
- }
-}
-
-/// Count lines when text is wrapped at given width.
-/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation.
-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.
-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 {
- // First word on line
- if word_width > width {
- // Word is longer than width, it will be split by character
- // Count how many lines it takes
- 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 {
- // Subsequent word - need space before it
- let needed = current_line_width + 1 + word_width;
- if needed > width {
- // Word doesn't fit, start new line
- line_count += 1;
- if word_width > width {
- // Word itself is too long, will be split
- 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;
- }
- }
- }
-
- // Count the last line of this logical line
- if line_started {
- line_count += 1;
- }
- }
-
- // Handle case where text has no lines() output (empty or just whitespace)
- if line_count == 0 {
- line_count = 1;
- current_line_width = 0;
- }
-
- (line_count, current_line_width)
+ // Render the component tree
+ tree.render(frame, inner_area, ctx);
}
-/// Convert markdown to styled spans (existing function, kept as-is)
+/// Convert markdown to styled spans
pub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec<Line<'a>> {
let parser = Parser::new(text);
let mut lines: Vec<Vec<Span<'a>>> = vec![Vec::new()];