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/terminal.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/terminal.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 278 |
1 files changed, 0 insertions, 278 deletions
diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs deleted file mode 100644 index f8089323..00000000 --- a/crates/atuin-ai/src/tui/terminal.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crossterm::{ - cursor, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use eyre::{Context, Result, bail}; -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 -/// even if the application panics. -/// -/// This must be called before creating the TerminalGuard to ensure proper cleanup -/// during panics. The hook will: -/// 1. Disable raw mode (restoring normal terminal behavior) -/// 2. Call the original panic hook to display panic information -/// -/// # Implementation Note -/// This satisfies TUI-07: Terminal remains usable after panic by ensuring -/// disable_raw_mode() is called before the panic message is displayed. -pub fn install_panic_hook() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - // Attempt to restore terminal - ignore errors since we're already panicking - let _ = disable_raw_mode(); - // Call original hook to display panic with backtrace - original_hook(panic_info); - })); -} - -/// Minimum viewport height -const MIN_VIEWPORT_HEIGHT: u16 = 10; - -/// Margin to leave below viewport for shell prompt -const VIEWPORT_BOTTOM_MARGIN: u16 = 2; - -/// Guards terminal lifecycle, ensuring proper setup and cleanup. -/// -/// # Lifecycle -/// - **Setup** (`new()`): Captures cursor position, enables raw mode, creates inline viewport -/// - **Cleanup** (`Drop`): Clears terminal, disables raw mode -/// -/// # Dynamic Viewport Sizing -/// The viewport starts at 15 lines (enough for simple commands) and grows -/// dynamically when content requires more space. Use `ensure_height()` before -/// rendering to grow the viewport if needed. -/// -/// # Safety Features -/// - Non-TTY detection: Returns error early if stdout is not a terminal -/// - Panic recovery: Works with `install_panic_hook()` to restore terminal after panic -/// - Drop-based cleanup: Ensures terminal is restored on normal exit -/// -/// # Example -/// ```no_run -/// use atuin_ai::tui::{install_panic_hook, TerminalGuard}; -/// -/// install_panic_hook(); // Once at program start -/// let mut guard = TerminalGuard::new(true)?; -/// let terminal = guard.terminal(); -/// // ... use terminal ... -/// // Drop automatically cleans up -/// # Ok::<(), eyre::Report>(()) -/// ``` -pub struct TerminalGuard { - terminal: Terminal<CrosstermBackend<Stdout>>, - anchor_col: u16, - keep_output: bool, - viewport_height: u16, - popup_mode: bool, -} - -impl TerminalGuard { - /// Create a new TerminalGuard, initializing the terminal for inline TUI mode. - /// - /// # Arguments - /// * `keep_output` - If true, preserve TUI output on exit; if false, clear it - /// - /// # Process - /// 1. Check if stdout is a terminal (non-TTY detection) - /// 2. Capture cursor position for inline rendering anchor - /// 3. Enable raw mode for keyboard input - /// 4. Create terminal with inline viewport - /// - /// # Errors - /// - Returns error if stdout is not a terminal (e.g., piped or redirected) - /// - Returns error if terminal initialization fails - /// - /// # Implementation Note - /// Cursor position is captured BEFORE enabling raw mode because some terminals - /// may report position differently after raw mode is enabled. - pub fn new(keep_output: bool) -> Result<Self> { - // Non-TTY check: fail early if stdout is not a terminal - if !stdout().is_terminal() { - bail!( - "atuin-ai requires a terminal (TTY) but stdout is not a terminal. \ - This typically happens when output is piped or redirected." - ); - } - - // Get terminal size and calculate viewport height - let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24)); - let viewport_height = term_height - .saturating_sub(VIEWPORT_BOTTOM_MARGIN) - .max(MIN_VIEWPORT_HEIGHT); - - // Capture cursor position BEFORE raw mode for accurate anchor - let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0); - - // Enable raw mode for keyboard input - enable_raw_mode().context("failed to enable raw mode")?; - - // Create terminal with fixed viewport based on terminal size - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(viewport_height), - }, - ) - .context("failed to create terminal with inline viewport")?; - - Ok(Self { - terminal, - 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, - }) - } - - /// Returns the current viewport height. - /// - /// The viewport is fixed at creation time based on terminal size. - /// Content that exceeds this height will be scrolled automatically. - /// - /// The `_needed` parameter is kept for API compatibility but ignored - - /// we no longer attempt to resize the viewport dynamically since that - /// operation can fail unpredictably with inline viewports. - pub fn ensure_height(&mut self, _needed: u16) -> Result<u16> { - Ok(self.viewport_height) - } - - /// Get the current viewport height. - pub fn viewport_height(&self) -> u16 { - self.viewport_height - } - - /// Get mutable reference to the underlying terminal. - /// - /// Use this to perform rendering operations. - pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> { - &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 - /// the terminal was initialized. - pub fn anchor_col(&self) -> u16 { - self.anchor_col - } -} - -/// Cleanup terminal state when TerminalGuard is dropped. -/// -/// This implements TUI-08: Terminal restores correctly after normal exit. -/// -/// # Cleanup Process -/// 1. Conditionally clear terminal content (based on keep_output flag) -/// 2. Disable raw mode (restore normal terminal behavior) -/// -/// # Error Handling -/// Errors are intentionally ignored during cleanup since: -/// - We're already exiting and can't meaningfully handle errors -/// - Best-effort restoration is better than panicking during Drop -/// - 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(); - } - - // Disable raw mode to restore normal terminal behavior - ignore errors - let _ = disable_raw_mode(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_panic_hook_installation() { - // Test that panic hook can be installed without error - install_panic_hook(); - // Installing again should work (replaces previous hook) - install_panic_hook(); - } - - // Note: Cannot easily test TerminalGuard::new() in CI since it requires a TTY. - // Manual testing required for: - // 1. Non-TTY detection: echo "" | cargo run -p atuin-ai -- inline - // 2. Drop cleanup: Run inline command, press Esc, verify terminal is normal - // 3. Panic recovery: Add panic!("test") after TerminalGuard::new(), verify terminal is usable -} |
