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/popup.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/popup.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/popup.rs | 363 |
1 files changed, 363 insertions, 0 deletions
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, + ) + } +} |
