aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/app.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-02-24 11:48:20 -0800
committerGitHub <noreply@github.com>2026-02-24 11:48:20 -0800
commit6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch)
tree18ebbb710cea24e30bc69b5d6bc807518a950746 /crates/atuin-ai/src/tui/app.rs
parentfix: forward $PATH to tmux popup in zsh (#3198) (diff)
downloadatuin-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/app.rs')
-rw-r--r--crates/atuin-ai/src/tui/app.rs157
1 files changed, 157 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/app.rs b/crates/atuin-ai/src/tui/app.rs
new file mode 100644
index 00000000..ecb1eb81
--- /dev/null
+++ b/crates/atuin-ai/src/tui/app.rs
@@ -0,0 +1,157 @@
+use super::state::{AppMode, AppState, ExitAction};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use tui_textarea::{Input, Key};
+
+/// Thin wrapper around AppState for compatibility
+/// All state lives in AppState, this just provides the handle_key interface
+pub struct App {
+ pub state: AppState,
+}
+
+impl App {
+ pub fn new() -> Self {
+ Self {
+ state: AppState::new(),
+ }
+ }
+
+ /// Handle a key event. Returns true if render is needed.
+ pub fn handle_key(&mut self, key: KeyEvent) -> bool {
+ match self.state.mode {
+ AppMode::Input => self.handle_input_key(key),
+ AppMode::Generating => self.handle_generating_key(key),
+ AppMode::Streaming => self.handle_streaming_key(key),
+ AppMode::Review => self.handle_review_key(key),
+ AppMode::Error => self.handle_error_key(key),
+ }
+ }
+
+ fn handle_input_key(&mut self, key: KeyEvent) -> bool {
+ // Handle special keys ourselves
+ match key.code {
+ KeyCode::Esc => {
+ self.state.exit(ExitAction::Cancel);
+ return true;
+ }
+ KeyCode::Enter => {
+ if self.state.input_is_empty() {
+ self.state.exit(ExitAction::Cancel);
+ } else {
+ self.state.start_generating();
+ }
+ return true;
+ }
+ _ => {}
+ }
+
+ // Delegate all other keys to textarea
+ // Manually convert crossterm KeyEvent to tui-textarea Input
+ // (needed due to crossterm version mismatch)
+ let tui_key = match key.code {
+ KeyCode::Char(c) => Key::Char(c),
+ KeyCode::Backspace => Key::Backspace,
+ KeyCode::Delete => Key::Delete,
+ KeyCode::Left => Key::Left,
+ KeyCode::Right => Key::Right,
+ KeyCode::Up => Key::Up,
+ KeyCode::Down => Key::Down,
+ KeyCode::Home => Key::Home,
+ KeyCode::End => Key::End,
+ KeyCode::PageUp => Key::PageUp,
+ KeyCode::PageDown => Key::PageDown,
+ KeyCode::Tab => Key::Tab,
+ _ => Key::Null,
+ };
+
+ if tui_key != Key::Null {
+ let input = Input {
+ key: tui_key,
+ ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
+ alt: key.modifiers.contains(KeyModifiers::ALT),
+ shift: key.modifiers.contains(KeyModifiers::SHIFT),
+ };
+ self.state.textarea.input(input);
+ }
+ true
+ }
+
+ fn handle_generating_key(&mut self, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.cancel_generation();
+ true
+ }
+ _ => false, // Discard other keys during generation
+ }
+ }
+
+ fn handle_streaming_key(&mut self, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.cancel_streaming();
+ true
+ }
+ _ => false, // Ignore other keys during streaming
+ }
+ }
+
+ fn handle_review_key(&mut self, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.confirmation_pending = false; // Clear confirmation state
+ self.state.exit(ExitAction::Cancel);
+ true
+ }
+ KeyCode::Enter => {
+ let cmd = self.state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ if self.state.is_current_command_dangerous() && !self.state.confirmation_pending
+ {
+ // First Enter on dangerous command: enter confirmation mode
+ self.state.confirmation_pending = true;
+ } else {
+ // Second Enter (confirmation), or non-dangerous command: execute
+ self.state.confirmation_pending = false;
+ self.state.exit(ExitAction::Execute(cmd));
+ }
+ }
+ true
+ }
+ KeyCode::Tab => {
+ let cmd = self.state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ self.state.confirmation_pending = false; // Clear on Tab too
+ self.state.exit(ExitAction::Insert(cmd));
+ }
+ true
+ }
+ KeyCode::Char('f') => {
+ // Changed from 'e' to 'f' for follow-up mode
+ self.state.confirmation_pending = false; // Clear on follow-up
+ self.state.start_edit_mode();
+ true
+ }
+ _ => false,
+ }
+ }
+
+ fn handle_error_key(&mut self, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Esc => {
+ self.state.exit(ExitAction::Cancel);
+ true
+ }
+ KeyCode::Enter | KeyCode::Char('r') => {
+ self.state.retry();
+ true
+ }
+ _ => false,
+ }
+ }
+}
+
+impl Default for App {
+ fn default() -> Self {
+ Self::new()
+ }
+}