aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/render.rs
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-03-09 14:28:32 -0700
committerGitHub <noreply@github.com>2026-03-09 14:28:32 -0700
commitb4a17e4346c97d837d0ee3a3a55c5ceca789a3e8 (patch)
tree4be327a9f902455a870232d36e2cd4fb4206804d /crates/atuin-ai/src/tui/render.rs
parentchore: update to Rust 1.94 (#3247) (diff)
downloadatuin-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.rs106
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];