aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src
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
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')
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs8
-rw-r--r--crates/atuin-ai/src/commands/inline.rs65
-rw-r--r--crates/atuin-ai/src/tui/mod.rs2
-rw-r--r--crates/atuin-ai/src/tui/popup.rs363
-rw-r--r--crates/atuin-ai/src/tui/render.rs106
-rw-r--r--crates/atuin-ai/src/tui/terminal.rs77
-rw-r--r--crates/atuin-ai/src/tui/view_model.rs75
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