aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/terminal.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/terminal.rs
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-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.rs278
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
-}