diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-26 19:19:47 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 02:19:47 +0000 |
| commit | b649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch) | |
| tree | ca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/popup.rs | |
| parent | fix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff) | |
| download | atuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip | |
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with
[eye-declare](https://github.com/BinaryMuse/eye-declare), and updates
the TUI to feel more terminal-native: output appears inline and persists
in scrollback, so you can scroll up and look at previous conversations
for reference.
The "review" state — which used to exist between the LLM generating a
response and the user either executing or following up — has been
removed; just start typing to follow up with the LLM, or press `enter`
at the empty input box to execute the suggested command.
<img width="1203" height="633" alt="image"
src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94"
/>
Diffstat (limited to 'crates/atuin-ai/src/tui/popup.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/popup.rs | 363 |
1 files changed, 0 insertions, 363 deletions
diff --git a/crates/atuin-ai/src/tui/popup.rs b/crates/atuin-ai/src/tui/popup.rs deleted file mode 100644 index c62b0e62..00000000 --- a/crates/atuin-ai/src/tui/popup.rs +++ /dev/null @@ -1,363 +0,0 @@ -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, - ) - } -} |
