aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/render.rs
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/atuin-ai/src/tui/render.rs
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/atuin-ai/src/tui/render.rs')
-rw-r--r--crates/atuin-ai/src/tui/render.rs515
1 files changed, 18 insertions, 497 deletions
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()];