diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-02-24 11:48:20 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-24 11:48:20 -0800 |
| commit | 6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch) | |
| tree | 18ebbb710cea24e30bc69b5d6bc807518a950746 /crates/atuin-ai/src/tui/terminal.rs | |
| parent | fix: forward $PATH to tmux popup in zsh (#3198) (diff) | |
| download | atuin-6ea760bb6b36da241961e8ecd60cb2c5e15c0a78.zip | |
feat: Generate commands or ask questions with `atuin ai` (#3199)
This PR refines the system created in #3178 to be suitable for a v1
release.
---
## Overview
`atuin-ai` is a separate binary that allows for generating commands and
asking questions from the command line.
It is fully opt-in.
## Usage
`atuin ai init` will output bindings for your shell. Currently, bash,
zsh, and fish are supported.
```bash
eval "$(atuin ai init)"
```
Once the hooks are installed, just press `?` on an empty prompt line to
call up the TUI.
`atuin ai` requires an account on [Atuin Hub](https://hub.atuin.sh/);
you will be prompted to log in on first use.
## Features
### Command generation
Prompt the LLM to create a command, and get one back, no fuss. Press
`enter` to run, or `tab` to insert.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
### Follow-up
You can follow-up with `f` to specify a refinement prompt to update the
command that will be inserted.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can also follow-up with questions to get responses in natural
language.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > What other useful flags to `docker ps` should I know? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ Here are some handy `docker ps` flags: │
│ │
│ - `-q` — Only show container IDs (great for piping to │
│ other commands) │
│ - `-s` — Show container sizes │
│ - `-n 5` — Show the last 5 created containers │
│ - `-l` — Show only the latest created container │
│ - `--no-trunc` — Don't truncate output (shows full IDs and │
│ commands) │
│ - `-f` or `--filter` — Filter by condition, e.g.: │
│ - `-f status=exited` — only exited containers │
│ - `-f name=myapp` — filter by name │
│ - `-f ancestor=nginx` — filter by image │
│ - `--format` — Custom output using Go templates, e.g.: │
│ `--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"` │
│ │
│ A common combo is `docker ps -aq` to get all container │
│ IDs, useful for bulk operations like `docker rm $(docker │
│ ps -aq)`. │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can use `enter` or `tab` at any time to run or insert the last
suggested command, even if it was suggested in a previous turn.
### Conversational and search usage
If you prompt the LLM with a question that doesn't imply you want to
generate a command, it can respond in natural language, and use web
search if necessary to fetch the data it needs.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > What is the latest version of atuin? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ ✓ Used 2 tools │
│ │
│ The latest version of Atuin is **v18.12.0**, available on │
│ the [GitHub releases │
│ page](https://github.com/atuinsh/atuin/releases). │
│ │
└─────────────────────────────────[f]: Follow-up [Esc]: Cancel┘
```
### Dangerous or low-confidence command detection
The LLM scores its confidence in the command, as well as how dangerous
the command is. This information is shown if a threshold is exceeded,
and requires an extra confirmation step before running automatically
with `enter`.
The Atuin Hub server also monitors suggested commands for dangerous
patterns the LLM didn't catch, and appends its own assessment at the end
of the LLM's own assessment.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Delete all files from $HOME │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ rm -rf $HOME/* │
│ │
│ ! ⚠️ This will PERMANENTLY delete ALL files and directories │
│ in your home directory, including documents, downloads, │
│ configurations, SSH keys, and everything else. This is │
│ irreversible and will likely break your system. Also note │
│ this won't delete hidden (dot) files — if you want those │
│ too, that's even more destructive.; [Server] Recursive │
│ delete of critical directory │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'crates/atuin-ai/src/tui/terminal.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 203 |
1 files changed, 203 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs new file mode 100644 index 00000000..2e0bcbaa --- /dev/null +++ b/crates/atuin-ai/src/tui/terminal.rs @@ -0,0 +1,203 @@ +use crossterm::{ + cursor, + terminal::{disable_raw_mode, enable_raw_mode}, +}; +use eyre::{Context, Result, bail}; +use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend}; +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()?; +/// 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, +} + +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, + }) + } + + /// 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 + } + + /// 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) { + // 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 +} |
