diff options
Diffstat (limited to 'crates/atuin-ai/src/tui/render.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 674 |
1 files changed, 674 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs new file mode 100644 index 00000000..0b6341e6 --- /dev/null +++ b/crates/atuin-ai/src/tui/render.rs @@ -0,0 +1,674 @@ +use atuin_client::theme::{Meaning, Theme}; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui::{ + Frame, + backend::FromCrossterm, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block as RatatuiBlock, Borders, Padding, Paragraph, Wrap}, +}; +use tui_textarea::TextArea; + +use super::spinner::active_frame; +use super::state::AppState; +use super::view_model::{Blocks, Content, WarningKind}; + +/// Fixed card width for the TUI +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, +} + +/// Calculate the height needed to render the current state. +/// Used to dynamically resize the viewport before rendering. +pub fn calculate_needed_height(state: &AppState) -> u16 { + use super::state::AppMode; + + let view = Blocks::from_state(state); + let content_width = usize::from(CARD_WIDTH.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)); + } + + // In Streaming/Generating mode, always reserve space for spinner block even during + // the 200ms delay when it's not yet shown. This prevents the UI from briefly + // shrinking and scrolling away the user message. + let has_spinner_block = view.items.iter().any(|b| { + b.content + .iter() + .any(|c| matches!(c, Content::Spinner { .. })) + }); + if matches!(state.mode, AppMode::Streaming | AppMode::Generating) && !has_spinner_block { + // Reserve space for separator (2 lines) + spinner block (1 line) + total_height = total_height.saturating_add(3); + } + + // Add borders (2) + top padding (1), minimum 5 + total_height.saturating_add(3).max(5) +} + +/// Main render function: derives view model from state, then renders it +pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { + // PURE DERIVATION: view model is always rebuilt from state + let view = Blocks::from_state(state); + + // Render the derived view model + render_view(frame, &view, ctx); +} + +fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { + let area = frame.area(); + + // Calculate frame dimensions (fixed width, min 32 if terminal is narrow) + let desired_width = CARD_WIDTH.min(area.width.saturating_sub(2)).max(32); + let content_width = usize::from(desired_width.saturating_sub(4)).max(1); + + // Position at anchor_col + let max_x = area.x + area.width.saturating_sub(desired_width); + let preferred_x = area.x + ctx.anchor_col.saturating_sub(2); + + // Calculate height from view model + 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 desired_height = total_height + .saturating_add(3) // borders (2) + top padding (1), no bottom padding + .max(5); + + // Cap card height at viewport height to prevent overflow + let actual_height = desired_height.min(area.height); + + // Calculate scroll offset (scroll to show bottom content when overflowing) + let scroll_offset = desired_height.saturating_sub(actual_height); + + let card = Rect { + x: preferred_x.min(max_x), + y: area.y, + width: desired_width, + height: actual_height, + }; + + // Get title from first block (if any) + let title = view + .items + .first() + .and_then(|b| b.title.as_deref()) + .unwrap_or("Describe the command you'd like to generate:"); + + // Create bordered frame + // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) + let outer_block = RatatuiBlock::default() + .borders(Borders::ALL) + .title(title) + .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) + .padding(Padding::new(1, 1, 1, 0)); + + 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, view, ctx, inner_area, card.width, scroll_offset); +} + +fn render_blocks_content( + frame: &mut Frame, + view: &Blocks, + 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 view.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 view.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) +} + +/// Convert markdown to styled spans (existing function, kept as-is) +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()]; + let mut current_line = 0; + + let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); + let code_style = Style::from_crossterm(theme.as_style(Meaning::Important)); + let mut style_stack: Vec<Style> = vec![base_style]; + let mut in_code_block = false; + + for event in parser { + match event { + Event::Start(Tag::Strong) => { + let bold_style = style_stack + .last() + .copied() + .unwrap_or(base_style) + .add_modifier(Modifier::BOLD); + style_stack.push(bold_style); + } + Event::End(TagEnd::Strong) => { + style_stack.pop(); + } + Event::Start(Tag::Emphasis) => { + let underline_style = style_stack + .last() + .copied() + .unwrap_or(base_style) + .add_modifier(Modifier::UNDERLINED); + style_stack.push(underline_style); + } + Event::End(TagEnd::Emphasis) => { + style_stack.pop(); + } + Event::Start(Tag::CodeBlock(_)) => { + in_code_block = true; + // Start new line for code block + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + // Ensure blank line after code block + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::Code(code) => { + lines[current_line].push(Span::styled(format!("`{}`", code), code_style)); + } + Event::Text(text) => { + let current_style = if in_code_block { + // Use Important style for code block content + code_style + } else { + style_stack.last().copied().unwrap_or(base_style) + }; + 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(part.to_string(), current_style)); + } + } + } + Event::SoftBreak => { + let current_style = style_stack.last().copied().unwrap_or(base_style); + 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() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::Paragraph) => {} + _ => {} + } + } + + lines.into_iter().map(Line::from).collect() +} |
