diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-17 16:39:37 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-17 16:39:37 -0700 |
| commit | 2ef3d38ab316e67ae57f24c281dfe8614f8670d6 (patch) | |
| tree | ac04d9c75e291ed6558ce21aa827d5734873510c /crates/atuin-ai/src | |
| parent | feat: Report distro name with OS for distro-specific commands (#3289) (diff) | |
| download | atuin-2ef3d38ab316e67ae57f24c281dfe8614f8670d6.zip | |
chore: Replace atuin-ai rendering with component-oriented system (#3288)
Diffstat (limited to 'crates/atuin-ai/src')
| -rw-r--r-- | crates/atuin-ai/src/tui/component.rs | 186 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components.rs | 510 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 515 |
4 files changed, 716 insertions, 497 deletions
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()]; |
