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 | |
| 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')
| -rw-r--r-- | crates/atuin-ai/src/commands/debug_render.rs | 8 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 65 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/popup.rs | 363 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 106 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 77 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 75 |
7 files changed, 626 insertions, 70 deletions
diff --git a/crates/atuin-ai/src/commands/debug_render.rs b/crates/atuin-ai/src/commands/debug_render.rs index e78a418a..b35d73c9 100644 --- a/crates/atuin-ai/src/commands/debug_render.rs +++ b/crates/atuin-ai/src/commands/debug_render.rs @@ -219,6 +219,8 @@ pub async fn run(input_file: Option<String>, format: OutputFormat) -> Result<()> anchor_col: 0, textarea: Some(&state.textarea), max_height: debug_input.height, + popup_mode: false, + render_above: false, }; terminal.draw(|frame| { @@ -245,7 +247,11 @@ fn blocks_to_json(blocks: &Blocks) -> serde_json::Value { "title": block.title, "content": block.content.iter().map(content_to_json).collect::<Vec<_>>() }) - }).collect::<Vec<_>>() + }).collect::<Vec<_>>(), + "status_bar": blocks.status_bar.as_ref().map(|sb| serde_json::json!({ + "frame": sb.frame, + "text": sb.text + })) }) } diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index 67241574..cd670bf8 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -375,7 +375,7 @@ impl DebugStateLogger { .unwrap_or(0); // Calculate the actual content height needed for this state - let content_height = calculate_needed_height(state); + let content_height = calculate_needed_height(state, 0); let mut state_json = state_to_json(state); // Add dimensions for accurate replay @@ -405,7 +405,22 @@ async fn run_inline_tui( debug_state_file: Option<String>, settings: &atuin_client::settings::Settings, ) -> Result<(Action, String)> { - // Initialize terminal guard and app state + // Detect popup mode (only on Unix where atuin-hex socket is available) + #[cfg(unix)] + let mut popup_state = crate::tui::popup::try_setup_popup(); + #[cfg(not(unix))] + let mut popup_state: Option<()> = None; + + let popup_mode = popup_state.is_some(); + + // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline + #[cfg(unix)] + let mut guard = if let Some(ref ps) = popup_state { + TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)? + } else { + TerminalGuard::new(keep_output)? + }; + #[cfg(not(unix))] let mut guard = TerminalGuard::new(keep_output)?; let mut app = App::new(); if let Some(prompt) = initial_prompt { @@ -451,16 +466,54 @@ async fn run_inline_tui( loop { // Ensure viewport is large enough for current content (capped at terminal height) - let needed_height = calculate_needed_height(&app.state); + // In popup mode, use the actual popup width for accurate height calculation + let card_width = if popup_mode { + #[cfg(unix)] + { + popup_state + .as_ref() + .map(|ps| { + ps.current_rect + .width + .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2) + }) + .unwrap_or(0) + } + #[cfg(not(unix))] + { + 0 + } + } else { + 0 + }; + let needed_height = calculate_needed_height(&app.state, card_width); + + // Grow popup dynamically as content arrives + #[cfg(unix)] + if let Some(ref mut ps) = popup_state { + // Add vertical margin for visual separation from terminal content + let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2); + if let Some(new_rect) = ps.fit_to(popup_height) { + guard.resize_popup(new_rect)?; + } + } + let actual_height = guard.ensure_height(needed_height)?; // Render current state let anchor_col = guard.anchor_col(); + #[cfg(unix)] + let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above); + #[cfg(not(unix))] + let render_above = false; + let ctx = RenderContext { theme, anchor_col, textarea: Some(&app.state.textarea), max_height: actual_height, + popup_mode, + render_above, }; // Handle draw errors gracefully - cursor position reads can fail during resize if let Err(e) = guard.terminal().draw(|frame| { @@ -597,6 +650,12 @@ async fn run_inline_tui( } } + // Restore popup area before guard drops (guard skips cleanup in popup mode) + #[cfg(unix)] + if let Some(ref ps) = popup_state { + crate::tui::popup::restore(ps); + } + // Map exit action to return value let result = match app.state.exit_action { Some(ExitAction::Execute(cmd)) => (Action::Execute, cmd), diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs index dbf4457b..03a9c007 100644 --- a/crates/atuin-ai/src/tui/mod.rs +++ b/crates/atuin-ai/src/tui/mod.rs @@ -1,5 +1,7 @@ pub mod app; pub mod event; +#[cfg(unix)] +pub mod popup; pub mod render; pub mod spinner; pub mod state; diff --git a/crates/atuin-ai/src/tui/popup.rs b/crates/atuin-ai/src/tui/popup.rs new file mode 100644 index 00000000..c62b0e62 --- /dev/null +++ b/crates/atuin-ai/src/tui/popup.rs @@ -0,0 +1,363 @@ +use ratatui::layout::Rect; + +/// Maximum popup height (lines). Keeps context visible around the popup. +const MAX_POPUP_HEIGHT: u16 = 24; + +/// Minimum usable popup height. +const MIN_POPUP_HEIGHT: u16 = 5; + +/// Initial popup height — just enough for input + a small response. +const INITIAL_POPUP_HEIGHT: u16 = 5; + +/// Margin around the card in popup mode. +pub(crate) const POPUP_MARGIN: u16 = 0; + +/// Screen state captured from atuin-hex's screen server. +pub struct SavedScreen { + #[allow(dead_code)] + pub rows: u16, + #[allow(dead_code)] + pub cols: u16, + pub cursor_row: u16, + pub cursor_col: u16, + /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. + pub rows_data: Vec<Vec<u8>>, +} + +/// Popup mode state: saved screen + computed placement. +pub struct PopupState { + pub saved_screen: SavedScreen, + /// Maximum rect computed from placement (the ceiling for growth). + pub max_rect: Rect, + /// Current rect — starts small, grows as content arrives. + pub current_rect: Rect, + pub scroll_offset: u16, + /// True when the popup renders above the cursor (input at bottom of card). + pub render_above: bool, +} + +impl PopupState { + /// Resize the popup to fit `needed` lines of content. + /// + /// Grows or shrinks the popup as needed (clamped to max_rect / INITIAL_POPUP_HEIGHT). + /// When growing, clears the new rect area. When shrinking, restores freed rows + /// from the saved screen data. + /// + /// Returns `Some(new_rect)` if the size changed (caller must resize terminal), + /// or `None` if no change is needed. + pub fn fit_to(&mut self, needed: u16) -> Option<Rect> { + let new_height = needed.clamp(INITIAL_POPUP_HEIGHT, self.max_rect.height); + if new_height == self.current_rect.height { + return None; + } + + let old_rect = self.current_rect; + let growing = new_height > old_rect.height; + + if self.render_above { + let new_y = self.max_rect.y + self.max_rect.height - new_height; + self.current_rect = Rect::new(old_rect.x, new_y, old_rect.width, new_height); + } else { + self.current_rect = Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height); + } + + if growing { + // Clear the entire new rect so the new Terminal doesn't leave + // ghost content from the old card. + self.clear_rows( + self.current_rect.y, + self.current_rect.y + self.current_rect.height, + ); + } else { + // Shrinking: restore freed rows from saved screen data, then + // clear the new (smaller) rect for the re-rendered card. + self.restore_rows(&old_rect); + self.clear_rows( + self.current_rect.y, + self.current_rect.y + self.current_rect.height, + ); + } + + Some(self.current_rect) + } + + /// Clear a range of terminal rows within the popup width. + fn clear_rows(&self, from_row: u16, to_row: u16) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let mut out = stdout(); + for row in from_row..to_row { + let _ = execute!( + out, + MoveTo(self.current_rect.x, row), + SetAttribute(Attribute::Reset) + ); + let _ = write!( + out, + "{:width$}", + "", + width = self.current_rect.width as usize + ); + } + let _ = out.flush(); + } + + /// Restore rows that were freed by shrinking — the rows in old_rect + /// that are no longer covered by current_rect. + fn restore_rows(&self, old_rect: &Rect) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let mut out = stdout(); + + // Determine which rows are freed + let (freed_start, freed_end) = if self.render_above { + // Shrinking from above: freed rows are at the old top + (old_rect.y, self.current_rect.y) + } else { + // Shrinking from below: freed rows are at the old bottom + ( + self.current_rect.y + self.current_rect.height, + old_rect.y + old_rect.height, + ) + }; + + for row in freed_start..freed_end { + let source_row = (row + self.scroll_offset) as usize; + + // Clear the popup region + let _ = execute!(out, MoveTo(old_rect.x, row), SetAttribute(Attribute::Reset),); + let _ = write!(out, "{:width$}", "", width = old_rect.width as usize); + + // Write back saved row data from column 0 + let _ = execute!(out, MoveTo(0, row)); + if let Some(row_bytes) = self.saved_screen.rows_data.get(source_row) { + let _ = out.write_all(row_bytes); + } + } + let _ = out.flush(); + } +} + +/// Try to set up popup overlay mode. +/// +/// Checks for `ATUIN_HEX_SOCKET`, fetches screen state, computes placement, +/// and scrolls the terminal if needed. Returns `None` if popup mode is not +/// available (no socket, fetch failed, etc.), in which case the caller should +/// fall back to inline mode. +pub fn try_setup_popup() -> Option<PopupState> { + use std::io::Write; + + let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok()?; + let saved = fetch_screen_state(&socket_path)?; + + let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((saved.cols, saved.rows)); + // Full-width popup with margin for visual separation + let popup_width = term_cols; + let (rect, scroll, render_above) = compute_popup_placement( + saved.cursor_row, + saved.cursor_col, + term_rows, + term_cols, + popup_width, + ); + + // Scroll terminal up if needed to make room for the popup + if scroll > 0 { + let mut stdout = std::io::stdout(); + let _ = crossterm::execute!(stdout, crossterm::cursor::MoveTo(0, term_rows - 1)); + for _ in 0..scroll { + let _ = writeln!(stdout); + } + let _ = stdout.flush(); + } + + // Start with a small rect that grows as content arrives + let initial_height = INITIAL_POPUP_HEIGHT.min(rect.height); + let current_rect = if render_above { + // Anchor at the bottom of max_rect (near cursor), grow upward + Rect::new( + rect.x, + rect.y + rect.height - initial_height, + rect.width, + initial_height, + ) + } else { + // Anchor at the top of max_rect (near cursor), grow downward + Rect::new(rect.x, rect.y, rect.width, initial_height) + }; + + Some(PopupState { + saved_screen: saved, + max_rect: rect, + current_rect, + scroll_offset: scroll, + render_above, + }) +} + +/// Restore the screen area that was covered by the popup. +/// +/// Clears the popup region, then writes pre-formatted per-row ANSI bytes from +/// column 0 to correctly restore wide characters, colors, and all attributes. +pub fn restore(state: &PopupState) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let saved = &state.saved_screen; + let popup_rect = state.current_rect; + let scroll_offset = state.scroll_offset; + + let mut stdout = stdout(); + + for dy in 0..popup_rect.height { + let target_row = popup_rect.y + dy; + let source_row = (target_row + scroll_offset) as usize; + + // Clear only the popup region with spaces + let _ = execute!( + stdout, + MoveTo(popup_rect.x, target_row), + SetAttribute(Attribute::Reset), + ); + let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); + + // Write back full row ANSI data from column 0 + let _ = execute!(stdout, MoveTo(0, target_row)); + if let Some(row_bytes) = saved.rows_data.get(source_row) { + let _ = stdout.write_all(row_bytes); + } + } + + // Restore cursor position (adjusted for any scrolling) + let _ = execute!( + stdout, + MoveTo( + saved.cursor_col, + saved.cursor_row.saturating_sub(scroll_offset) + ) + ); + let _ = stdout.flush(); +} + +/// Connect to atuin-hex's Unix socket and fetch the current screen state. +/// +/// The wire format is: +/// ```text +/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] +/// [row_0_len: u32 BE][row_0_bytes...] +/// [row_1_len: u32 BE][row_1_bytes...] +/// ... +/// ``` +fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> { + use std::io::Read; + use std::os::unix::net::UnixStream; + use std::time::Duration; + + let mut stream = UnixStream::connect(socket_path).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + + let mut data = Vec::new(); + stream.read_to_end(&mut data).ok()?; + + if data.len() < 8 { + return None; + } + + let rows = u16::from_be_bytes([data[0], data[1]]); + let cols = u16::from_be_bytes([data[2], data[3]]); + let cursor_row = u16::from_be_bytes([data[4], data[5]]); + let cursor_col = u16::from_be_bytes([data[6], data[7]]); + + let mut rows_data = Vec::with_capacity(rows as usize); + let mut offset = 8; + while offset + 4 <= data.len() { + let row_len = u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) as usize; + offset += 4; + if offset + row_len > data.len() { + break; + } + rows_data.push(data[offset..offset + row_len].to_vec()); + offset += row_len; + } + + Some(SavedScreen { + rows, + cols, + cursor_row, + cursor_col, + rows_data, + }) +} + +/// Compute popup placement for the AI card. +/// +/// Positions the popup near the cursor: below if there's room, above otherwise. +/// Uses a capped height (MAX_POPUP_HEIGHT) so the popup doesn't fill the screen. +/// +/// Returns `(popup_rect, scroll_offset, render_above)`: +/// - `render_above`: true when popup is above cursor (input should be at bottom) +/// - `scroll_offset`: lines the caller should scroll the terminal up +fn compute_popup_placement( + cursor_row: u16, + cursor_col: u16, + term_rows: u16, + term_cols: u16, + card_width: u16, +) -> (Rect, u16, bool) { + // Horizontal: anchor card near cursor, clamp to screen + let popup_w = card_width.min(term_cols); + let preferred_x = cursor_col.saturating_sub(2); + let max_x = term_cols.saturating_sub(popup_w); + let popup_x = preferred_x.min(max_x); + + // Vertical: use a reasonable height, not the full terminal + let max_h = MAX_POPUP_HEIGHT + .min(term_rows.saturating_sub(2)) + .max(MIN_POPUP_HEIGHT); + let space_above = cursor_row; + let space_below = term_rows.saturating_sub(cursor_row); + + if max_h <= space_below { + // Fits below cursor — input at top (close to prompt) + let popup_y = cursor_row; + (Rect::new(popup_x, popup_y, popup_w, max_h), 0, false) + } else if max_h <= space_above { + // Fits above cursor — input at bottom (close to prompt) + let popup_y = cursor_row.saturating_sub(max_h); + (Rect::new(popup_x, popup_y, popup_w, max_h), 0, true) + } else { + // Neither side fits fully — use whichever side has more space, + // scrolling the terminal if needed to reach MIN_POPUP_HEIGHT. + let render_above = space_above > space_below; + let available = if render_above { + space_above + } else { + space_below + }; + let h = available.max(MIN_POPUP_HEIGHT).min(max_h); + let scroll = h.saturating_sub(available); + let popup_y = if render_above { + cursor_row.saturating_sub(h + scroll) + } else { + cursor_row.saturating_sub(scroll) + }; + ( + Rect::new(popup_x, popup_y, popup_w, h), + scroll, + render_above, + ) + } +} 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]; diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs index 75bfd6e6..f8089323 100644 --- a/crates/atuin-ai/src/tui/terminal.rs +++ b/crates/atuin-ai/src/tui/terminal.rs @@ -3,7 +3,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode}, }; use eyre::{Context, Result, bail}; -use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend}; +use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend, layout::Rect}; use std::io::{IsTerminal, Stdout, stdout}; /// Install a panic hook that ensures the terminal is restored to a usable state @@ -65,6 +65,7 @@ pub struct TerminalGuard { anchor_col: u16, keep_output: bool, viewport_height: u16, + popup_mode: bool, } impl TerminalGuard { @@ -122,6 +123,56 @@ impl TerminalGuard { anchor_col, keep_output, viewport_height, + popup_mode: false, + }) + } + + /// Create a new TerminalGuard for popup overlay mode. + /// + /// In popup mode: + /// - Raw mode is not managed (atuin-hex owns it) + /// - The viewport is a fixed rect positioned over existing terminal content + /// - The popup area is pre-cleared to prevent background bleed-through + /// - Drop does not clear the viewport or disable raw mode + pub fn new_popup(popup_rect: Rect, anchor_col: u16) -> Result<Self> { + // Pre-clear the popup area before creating the ratatui terminal. + // Ratatui's diff-based rendering won't write "default" (space) cells on + // the first frame because its previous buffer is also all-default. By + // writing spaces to the terminal now, we ensure those positions are + // visually blank even if ratatui skips them. + { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::Write; + + let mut out = stdout(); + for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { + let _ = execute!( + out, + MoveTo(popup_rect.x, row), + SetAttribute(Attribute::Reset) + ); + let _ = write!(out, "{:width$}", "", width = popup_rect.width as usize); + } + let _ = out.flush(); + } + + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(popup_rect), + }, + ) + .context("failed to create terminal with fixed viewport")?; + + Ok(Self { + terminal, + anchor_col, + keep_output: false, + viewport_height: popup_rect.height, + popup_mode: true, }) } @@ -149,6 +200,24 @@ impl TerminalGuard { &mut self.terminal } + /// Resize the popup viewport to a new rect. + /// + /// Creates a fresh terminal with the updated Fixed viewport. The caller + /// is responsible for pre-clearing any newly exposed rows before calling + /// this (see `PopupState::grow_to`). + pub fn resize_popup(&mut self, new_rect: Rect) -> Result<()> { + self.viewport_height = new_rect.height; + let backend = CrosstermBackend::new(stdout()); + self.terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(new_rect), + }, + ) + .context("failed to resize popup terminal")?; + Ok(()) + } + /// Get the anchor column where the inline UI should be positioned. /// /// This is the column position where the cursor was located when @@ -173,6 +242,12 @@ impl TerminalGuard { /// - The panic hook provides a second layer of safety for abnormal exits impl Drop for TerminalGuard { fn drop(&mut self) { + if self.popup_mode { + // Popup mode: screen restoration handled by caller before drop. + // Raw mode is owned by atuin-hex, don't touch it. + return; + } + // Clear terminal content only if keep_output is false - ignore errors (best-effort) if !self.keep_output { let _ = self.terminal.clear(); diff --git a/crates/atuin-ai/src/tui/view_model.rs b/crates/atuin-ai/src/tui/view_model.rs index e89932d9..0a296065 100644 --- a/crates/atuin-ai/src/tui/view_model.rs +++ b/crates/atuin-ai/src/tui/view_model.rs @@ -87,11 +87,22 @@ pub struct Block { pub title: Option<String>, } +/// Status bar content shown on the bottom border during processing +#[derive(Debug, Clone)] +pub struct StatusBar { + /// Spinner animation frame + pub frame: usize, + /// Status text to display (e.g., "Thinking...", "run_bash (used 2 tools)") + pub text: String, +} + /// Complete view model - the rendering specification #[derive(Debug, Clone)] pub struct Blocks { pub items: Vec<Block>, pub footer: &'static str, + /// Transient status shown on bottom border during streaming/generating + pub status_bar: Option<StatusBar>, } /// Count non-suggest_command tool calls since the last user message @@ -146,6 +157,7 @@ impl Blocks { /// Also handles streaming text and mode-dependent UI. pub fn from_state(state: &AppState) -> Self { let mut items = Vec::new(); + let mut status_bar = None; // 1. Build blocks from conversation events for event in &state.events { @@ -255,25 +267,32 @@ impl Blocks { } } - // 2. AI response block (tool status + streaming text) - shown during Streaming only - // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above + // 2. AI response block (streaming text only) - shown during Streaming only + // Transient status (spinner, tool progress) goes to status_bar on the bottom border. + // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above. if state.mode == AppMode::Streaming { let (completed, in_flight) = count_tool_calls_since_last_user(&state.events); - let mut response_content = Vec::new(); - // Add tool status if there are any non-suggest_command tools - if completed > 0 || in_flight.is_some() { - response_content.push(Content::ToolStatus { - completed_count: completed, - current_label: in_flight.clone(), + // Tool status -> status bar + if let Some(ref label) = in_flight { + let text = if completed > 0 { + format!( + "{} (used {} tool{})", + label, + completed, + if completed == 1 { "" } else { "s" } + ) + } else { + label.clone() + }; + status_bar = Some(StatusBar { frame: state.spinner_frame, + text, }); } - // Add streaming text or spinner + // Spinner -> status bar (only when no text yet and no tool in-flight) if state.streaming_text.is_empty() { - // Check if enough time has passed to show spinner (200ms delay) - // Show spinner immediately if status event has arrived let should_show_spinner = state.streaming_status.is_some() || state .streaming_started @@ -281,29 +300,23 @@ impl Blocks { .unwrap_or(true); if should_show_spinner && in_flight.is_none() { - // Only show generating spinner if no tool is in-flight let status_text = state .streaming_status .as_ref() .map(|s| s.display_text().to_string()) .unwrap_or_else(|| "Generating...".to_string()); - response_content.push(Content::Spinner { + status_bar = Some(StatusBar { frame: state.spinner_frame, - status_text, + text: status_text, }); } } else { - // Show streaming text - response_content.push(Content::Text { - markdown: state.streaming_text.clone(), - }); - } - - // Add the response block if there's any content - if !response_content.is_empty() { + // Show streaming text as content items.push(Block { - content: response_content, + content: vec![Content::Text { + markdown: state.streaming_text.clone(), + }], separator_above: false, title: None, }); @@ -332,13 +345,9 @@ impl Blocks { .map(|s| s.display_text().to_string()) .unwrap_or_else(|| "Generating...".to_string()); - items.push(Block { - content: vec![Content::Spinner { - frame: state.spinner_frame, - status_text, - }], - separator_above: false, - title: None, + status_bar = Some(StatusBar { + frame: state.spinner_frame, + text: status_text, }); } AppMode::Streaming => { @@ -373,7 +382,11 @@ impl Blocks { // 7. Derive footer from mode and events let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending); - Self { items, footer } + Self { + items, + footer, + status_bar, + } } /// Derive footer text from current mode and conversation state |
