diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-03-09 14:28:32 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-09 14:28:32 -0700 |
| commit | b4a17e4346c97d837d0ee3a3a55c5ceca789a3e8 (patch) | |
| tree | 4be327a9f902455a870232d36e2cd4fb4206804d /crates/atuin-ai/src/tui/render.rs | |
| parent | chore: update to Rust 1.94 (#3247) (diff) | |
| download | atuin-b4a17e4346c97d837d0ee3a3a55c5ceca789a3e8.zip | |
feat: use pty proxy for rendering tui popups without clearing the terminal (#3234)
It feels much, much nicer this way. This has also been asked for pretty
consistently since we made inline rendering the default. Now we can have
everything :)
Maintains a shadow vt100 renderer so that we can restore the terminal
state upon popup close. This happens on a background thread, so our
impact on terminal performance should still be super minimal, if
anything
## Checks
- [ ] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [ ] I have checked that there are no existing pull requests for the
same thing
Diffstat (limited to 'crates/atuin-ai/src/tui/render.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 106 |
1 files changed, 72 insertions, 34 deletions
diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs index 0b6341e6..9326b0df 100644 --- a/crates/atuin-ai/src/tui/render.rs +++ b/crates/atuin-ai/src/tui/render.rs @@ -15,7 +15,7 @@ use super::state::AppState; use super::view_model::{Blocks, Content, WarningKind}; /// Fixed card width for the TUI -const CARD_WIDTH: u16 = 64; +pub(crate) const CARD_WIDTH: u16 = 64; pub struct RenderContext<'a> { pub theme: &'a Theme, @@ -23,15 +23,26 @@ pub struct RenderContext<'a> { 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. -pub fn calculate_needed_height(state: &AppState) -> u16 { - use super::state::AppMode; - +/// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default. +pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 { let view = Blocks::from_state(state); - let content_width = usize::from(CARD_WIDTH.saturating_sub(4)).max(1); + let w = if card_width > 0 { + card_width + } else { + CARD_WIDTH + }; + let content_width = usize::from(w.saturating_sub(4)).max(1); let mut total_height = 0u16; for (idx, block) in view.items.iter().enumerate() { @@ -43,19 +54,6 @@ pub fn calculate_needed_height(state: &AppState) -> u16 { 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) } @@ -70,19 +68,43 @@ pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { } fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { - let area = frame.area(); + let full_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); + // In popup mode, the viewport is already positioned and sized for the card. + // Clear it to prevent background bleed-through, then inset by margin for the card. + let (area, card_x, desired_width) = if ctx.popup_mode { + #[cfg(unix)] + use super::popup::POPUP_MARGIN; + #[cfg(not(unix))] + const POPUP_MARGIN: u16 = 0; + frame.render_widget(ratatui::widgets::Clear, full_area); + let inset = full_area.inner(ratatui::layout::Margin { + horizontal: POPUP_MARGIN, + vertical: POPUP_MARGIN, + }); + (inset, inset.x, inset.width) + } else { + let dw = CARD_WIDTH.min(full_area.width.saturating_sub(2)).max(32); + let max_x = full_area.x + full_area.width.saturating_sub(dw); + 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); - // 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); + // Build ordered items list — the active content (input/LLM response) + // should always be closest to the cursor/prompt: + // - Popup below cursor (render_above=false): reverse so active is at top + // - Popup above cursor (render_above=true): normal order, active is at bottom + // - Inline mode: normal order (no reversal) + let items: Vec<&super::view_model::Block> = if ctx.popup_mode && !ctx.render_above { + view.items.iter().rev().collect() + } else { + view.items.iter().collect() + }; // Calculate height from view model let mut total_height = 0u16; - for (idx, block) in view.items.iter().enumerate() { + 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 @@ -98,17 +120,24 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { // 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); + // Calculate scroll offset to keep the active content visible when overflowing. + // 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 { + 0 + } else { + desired_height.saturating_sub(actual_height) + }; let card = Rect { - x: preferred_x.min(max_x), + x: card_x, y: area.y, width: desired_width, height: actual_height, }; - // Get title from first block (if any) + // Get title from first block in ORIGINAL order (always the input block) let title = view .items .first() @@ -117,22 +146,31 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { // Create bordered frame // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) - let outer_block = RatatuiBlock::default() + let mut 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)); + // Status bar: transient status on the bottom border, left-aligned + if let Some(ref sb) = view.status_bar { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + let spinner = active_frame(sb.frame); + let status_text = format!(" {} {} ", spinner, sb.text); + outer_block = outer_block + .title_bottom(Line::from(Span::styled(status_text, style)).alignment(Alignment::Left)); + } + 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); + render_blocks_content(frame, &items, ctx, inner_area, card.width, scroll_offset); } fn render_blocks_content( frame: &mut Frame, - view: &Blocks, + items: &[&super::view_model::Block], ctx: &RenderContext, area: Rect, card_width: u16, @@ -143,7 +181,7 @@ fn render_blocks_content( // 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() { + 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 @@ -173,7 +211,7 @@ fn render_blocks_content( .split(area); let mut chunk_idx = 0; - for (idx, block) in view.items.iter().enumerate() { + 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]; |
