From bde6814411696f23e09f9d4a67fdc1e1fef55263 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 11 Mar 2026 23:33:30 -0700 Subject: feat: Initialize Atuin AI by default with `atuin init` (#3255) * Run Atuin AI's `init` during main `init` for bash, zsh, and fish * Note that logging into Hub will enable sync * Add instructions for users with existing sync accounts * Ensure daemon respects `auto_sync` setting * Update docs on disabling Atuin AI --- crates/atuin-ai/src/commands/inline.rs | 11 ++++- crates/atuin-daemon/src/components/sync.rs | 61 +++++++++++++++++++++++----- crates/atuin/src/command/client/init.rs | 28 +++++++++++-- crates/atuin/src/command/client/init/bash.rs | 11 ++++- crates/atuin/src/command/client/init/fish.rs | 11 ++++- crates/atuin/src/command/client/init/zsh.rs | 12 +++++- docs/docs/ai/introduction.md | 10 ++--- docs/docs/faq.md | 12 ++++++ 8 files changed, 129 insertions(+), 27 deletions(-) diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index 37e5ab81..803c7d72 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -77,10 +77,19 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu .active_hub_endpoint() .unwrap_or("https://hub.atuin.sh".to_string()); + let will_sync = settings.is_hub_sync(); + info!("No Hub session found, prompting for authentication"); println!("Atuin AI requires authenticating with Atuin Hub."); - println!("This is separate from your sync setup."); + if will_sync { + println!( + "Once logged in, your shell history will be synchronized via Atuin Hub if auto_sync is enabled or when manually syncing." + ) + } + println!( + "If you have an existing Atuin sync account, you can log in with your existing credentials." + ); println!("Press enter to begin (or esc to cancel)."); if !wait_for_login_confirmation()? { bail!("authentication canceled"); diff --git a/crates/atuin-daemon/src/components/sync.rs b/crates/atuin-daemon/src/components/sync.rs index 6217706a..314b375e 100644 --- a/crates/atuin-daemon/src/components/sync.rs +++ b/crates/atuin-daemon/src/components/sync.rs @@ -2,6 +2,8 @@ //! //! Handles periodic synchronization with the Atuin cloud server. +use std::time::Duration; + use eyre::Result; use rand::Rng; use tokio::sync::mpsc; @@ -23,6 +25,16 @@ enum SyncCommand { Stop, } +/// Sync state - tracks whether we're in normal operation or retrying after failure. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SyncState { + /// Normal operation. Periodic syncs only run if auto_sync is enabled. + Idle, + /// Retrying after a sync failure. Retries continue regardless of auto_sync + /// until the sync succeeds. + Retrying, +} + /// Sync component - handles periodic cloud synchronization. /// /// This component: @@ -123,29 +135,43 @@ async fn sync_loop(handle: DaemonHandle, mut cmd_rx: mpsc::Receiver // we may end up running a lot of syncs in a hot loop. ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut sync_state = SyncState::Idle; + loop { tokio::select! { _ = ticker.tick() => { - do_sync_tick( + let settings = handle.settings().await; + + // Skip periodic ticks if auto_sync is disabled AND we're not retrying + // a previous failure. Retries must continue regardless of auto_sync. + if !settings.auto_sync && sync_state == SyncState::Idle { + tracing::debug!("auto_sync disabled, skipping periodic sync tick"); + continue; + } + + sync_state = do_sync_tick( &handle, &history_store, &alias_store, &var_store, &mut ticker, max_interval, + &settings, ).await; } cmd = cmd_rx.recv() => { match cmd { Some(SyncCommand::ForceSync) => { tracing::info!("executing force sync"); - do_sync_tick( + let settings = handle.settings().await; + sync_state = do_sync_tick( &handle, &history_store, &alias_store, &var_store, &mut ticker, max_interval, + &settings, ).await; } Some(SyncCommand::Stop) | None => { @@ -159,6 +185,8 @@ async fn sync_loop(handle: DaemonHandle, mut cmd_rx: mpsc::Receiver } /// Execute a single sync tick. +/// +/// Returns the new sync state: `Idle` on success, `Retrying` on failure. async fn do_sync_tick( handle: &DaemonHandle, history_store: &HistoryStore, @@ -166,10 +194,8 @@ async fn do_sync_tick( var_store: &VarStore, ticker: &mut time::Interval, max_interval: f64, -) { - // Clone settings since we need them across await points - let settings = handle.settings().await.clone(); - + settings: &Settings, +) -> SyncState { tracing::info!("sync tick"); // Check if logged in @@ -177,17 +203,17 @@ async fn do_sync_tick( Ok(v) => v, Err(e) => { tracing::warn!("failed to check login status, skipping sync tick: {e}"); - return; + return SyncState::Idle; } }; if !logged_in { tracing::debug!("not logged in, skipping sync tick"); - return; + return SyncState::Idle; } // Perform the sync - let res = sync::sync(&settings, handle.store()).await; + let res = sync::sync(settings, handle.store()).await; match res { Err(e) => { @@ -206,10 +232,16 @@ async fn do_sync_tick( new_interval = max_interval; } - *ticker = time::interval(time::Duration::from_secs(new_interval as u64)); + *ticker = time::interval_at( + tokio::time::Instant::now() + Duration::from_secs(new_interval as u64), + time::Duration::from_secs(new_interval as u64), + ); ticker.reset_after(time::Duration::from_secs(new_interval as u64)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); tracing::error!("backing off, next sync tick in {new_interval}"); + + SyncState::Retrying } Ok((uploaded_count, downloaded_records)) => { tracing::info!( @@ -245,13 +277,20 @@ async fn do_sync_tick( // Reset backoff on success if ticker.period().as_secs() != settings.daemon.sync_frequency { - *ticker = time::interval(time::Duration::from_secs(settings.daemon.sync_frequency)); + *ticker = time::interval_at( + tokio::time::Instant::now() + + Duration::from_secs(settings.daemon.sync_frequency), + time::Duration::from_secs(settings.daemon.sync_frequency), + ); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); } // Store sync time if let Err(e) = Settings::save_sync_time().await { tracing::error!("failed to save sync time: {e}"); } + + SyncState::Idle } } } diff --git a/crates/atuin/src/command/client/init.rs b/crates/atuin/src/command/client/init.rs index 99fce33d..00c6c2fc 100644 --- a/crates/atuin/src/command/client/init.rs +++ b/crates/atuin/src/command/client/init.rs @@ -26,6 +26,10 @@ pub struct Cmd { /// Disable the binding of the Up Arrow key to atuin #[clap(long)] disable_up_arrow: bool, + + /// Disable the binding of ? to Atuin AI + #[clap(long)] + disable_ai: bool, } #[derive(Clone, Copy, ValueEnum, Debug)] @@ -97,13 +101,28 @@ $env.config = ( fn static_init(&self, tmux: &Tmux) { match self.shell { Shell::Zsh => { - zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); + zsh::init_static( + self.disable_up_arrow, + self.disable_ctrl_r, + self.disable_ai, + tmux, + ); } Shell::Bash => { - bash::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); + bash::init_static( + self.disable_up_arrow, + self.disable_ctrl_r, + self.disable_ai, + tmux, + ); } Shell::Fish => { - fish::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux); + fish::init_static( + self.disable_up_arrow, + self.disable_ctrl_r, + self.disable_ai, + tmux, + ); } Shell::Nu => { self.init_nu(tmux); @@ -136,6 +155,7 @@ $env.config = ( var_store, self.disable_up_arrow, self.disable_ctrl_r, + self.disable_ai, &settings.tmux, ) .await?; @@ -146,6 +166,7 @@ $env.config = ( var_store, self.disable_up_arrow, self.disable_ctrl_r, + self.disable_ai, &settings.tmux, ) .await?; @@ -156,6 +177,7 @@ $env.config = ( var_store, self.disable_up_arrow, self.disable_ctrl_r, + self.disable_ai, &settings.tmux, ) .await?; diff --git a/crates/atuin/src/command/client/init/bash.rs b/crates/atuin/src/command/client/init/bash.rs index 8e1349ed..745c239a 100644 --- a/crates/atuin/src/command/client/init/bash.rs +++ b/crates/atuin/src/command/client/init/bash.rs @@ -11,7 +11,7 @@ fn print_tmux_config(tmux: &Tmux) { } } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) { +pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let base = include_str!("../../../shell/atuin.bash"); let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { @@ -24,6 +24,12 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) { println!("__atuin_bind_ctrl_r={bind_ctrl_r}"); println!("__atuin_bind_up_arrow={bind_up_arrow}"); println!("{base}"); + + #[cfg(feature = "ai")] + if !disable_ai { + let bind_ai = atuin_ai::commands::init::generate_bash_integration(); + println!("{bind_ai}"); + } } pub async fn init( @@ -31,9 +37,10 @@ pub async fn init( vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, + disable_ai: bool, tmux: &Tmux, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, tmux); + init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::bash::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::bash::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/fish.rs b/crates/atuin/src/command/client/init/fish.rs index 4a1cda6f..6d6c8c23 100644 --- a/crates/atuin/src/command/client/init/fish.rs +++ b/crates/atuin/src/command/client/init/fish.rs @@ -37,7 +37,7 @@ fn print_bindings( println!("{indent}end"); } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) { +pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let indent = " ".repeat(4); let base = include_str!("../../../shell/atuin.fish"); @@ -84,6 +84,12 @@ pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) { ); println!("end"); + + #[cfg(feature = "ai")] + if !disable_ai { + let bind_ai = atuin_ai::commands::init::generate_fish_integration(); + println!("{bind_ai}"); + } } } @@ -92,9 +98,10 @@ pub async fn init( vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, + disable_ai: bool, tmux: &Tmux, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, tmux); + init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::fish::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::fish::var_config(&vars).await; diff --git a/crates/atuin/src/command/client/init/zsh.rs b/crates/atuin/src/command/client/init/zsh.rs index 0ab832fe..5d588aa0 100644 --- a/crates/atuin/src/command/client/init/zsh.rs +++ b/crates/atuin/src/command/client/init/zsh.rs @@ -11,7 +11,7 @@ fn print_tmux_config(tmux: &Tmux) { } } -pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) { +pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, disable_ai: bool, tmux: &Tmux) { let base = include_str!("../../../shell/atuin.zsh"); print_tmux_config(tmux); @@ -36,6 +36,13 @@ bindkey -M vicmd 'k' atuin-up-search-vicmd"; if !disable_up_arrow { println!("{BIND_UP_ARROW}"); } + + #[cfg(feature = "ai")] + if !disable_ai { + let bind_ai = atuin_ai::commands::init::generate_zsh_integration(); + + println!("{bind_ai}"); + } } } @@ -44,9 +51,10 @@ pub async fn init( vars: VarStore, disable_up_arrow: bool, disable_ctrl_r: bool, + disable_ai: bool, tmux: &Tmux, ) -> Result<()> { - init_static(disable_up_arrow, disable_ctrl_r, tmux); + init_static(disable_up_arrow, disable_ctrl_r, disable_ai, tmux); let aliases = atuin_dotfiles::shell::zsh::alias_config(&aliases).await; let vars = atuin_dotfiles::shell::zsh::var_config(&vars).await; diff --git a/docs/docs/ai/introduction.md b/docs/docs/ai/introduction.md index 0c235428..1c154992 100644 --- a/docs/docs/ai/introduction.md +++ b/docs/docs/ai/introduction.md @@ -1,18 +1,16 @@ # Atuin AI -Atuin AI is a subcommand that enables shell command generation and other information lookup via an LLM directly from your terminal. It is completely opt-in, and will not change the behavior of Atuin at all if you choose not to use it. +Atuin AI is a subcommand that enables shell command generation and other information lookup via an LLM directly from your terminal. Atuin AI requires an account on [Atuin Hub](https://hub.atuin.sh/), and you'll be prompted to login upon first use of the binary. ## Getting Started -Atuin AI currently supports zsh, bash, and fish shells. To get started, add the following to your shell's initialization file: +Atuin AI currently supports zsh, bash, and fish shells. Your shell's usual `atuin init` call will automatically bind the question mark key to the Atuin AI UI (only when the prompt is empty). -```bash -eval "$(atuin ai init)" -``` +!!! note "Disabling Atuin AI" -Once you've set it up and restarted your shell, you can invoke Atuin AI by pressing question mark (`?`) on an empty terminal line. + You can disable the default question mark key binding by passing `--disable-ai` to your shell's `atuin init` call. ## Settings diff --git a/docs/docs/faq.md b/docs/docs/faq.md index f5e867e7..32338ec9 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -37,6 +37,18 @@ eval "$(atuin init zsh --disable-up-arrow)" See [key binding](../configuration/key-binding.md) for more +## How do I remove the default question mark binding for Atuin AI? + +Open your shell config file, find the line containing `atuin init`. + +Add `--disable-ai` + +EG: + +``` +eval "$(atuin init zsh --disable-ai)" +``` + ## How do I edit a command instead of running it immediately? Press tab! By default, enter will execute a command, and tab will insert it ready for editing. -- cgit v1.3.1