aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
commit3223d93cb3c77ab02aa0a35f2a8314e447cee9a4 (patch)
tree3971a1f37f5fe3cadb747c5229a0d54e868e45e3 /crates/client
parentfix(client/sync): Pass through precise error on `SyncError::WrongKey` (diff)
downloadatuin-3223d93cb3c77ab02aa0a35f2a8314e447cee9a4.zip
chore: Separate daemon, client, server, and lib into crates
Diffstat (limited to 'crates/client')
-rw-r--r--crates/client/Cargo.toml103
-rw-r--r--crates/client/build.rs48
-rw-r--r--crates/client/src/atuin_client/api_client.rs197
-rw-r--r--crates/client/src/atuin_client/database.rs1313
-rw-r--r--crates/client/src/atuin_client/encryption.rs142
-rw-r--r--crates/client/src/atuin_client/history.rs686
-rw-r--r--crates/client/src/atuin_client/history/builder.rs154
-rw-r--r--crates/client/src/atuin_client/history/store.rs437
-rw-r--r--crates/client/src/atuin_client/meta.rs182
-rw-r--r--crates/client/src/atuin_client/mod.rs12
-rw-r--r--crates/client/src/atuin_client/ordering.rs31
-rw-r--r--crates/client/src/atuin_client/record/encryption.rs379
-rw-r--r--crates/client/src/atuin_client/record/mod.rs3
-rw-r--r--crates/client/src/atuin_client/record/sqlite_store.rs563
-rw-r--r--crates/client/src/atuin_client/record/sync.rs456
-rw-r--r--crates/client/src/atuin_client/secrets.rs223
-rw-r--r--crates/client/src/atuin_client/settings.rs1543
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs17
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs260
-rw-r--r--crates/client/src/atuin_client/theme.rs41
-rw-r--r--crates/client/src/atuin_client/utils.rs92
-rw-r--r--crates/client/src/atuin_common/api.rs22
-rw-r--r--crates/client/src/atuin_common/mod.rs58
-rw-r--r--crates/client/src/atuin_common/record.rs284
-rw-r--r--crates/client/src/atuin_common/shell.rs52
-rw-r--r--crates/client/src/atuin_common/tls.rs15
-rw-r--r--crates/client/src/atuin_common/utils.rs257
-rw-r--r--crates/client/src/atuin_history/mod.rs2
-rw-r--r--crates/client/src/atuin_history/sort.rs46
-rw-r--r--crates/client/src/atuin_history/stats.rs527
-rw-r--r--crates/client/src/atuin_pty_proxy/capture.rs470
-rw-r--r--crates/client/src/atuin_pty_proxy/debug.rs53
-rw-r--r--crates/client/src/atuin_pty_proxy/mod.rs17
-rw-r--r--crates/client/src/atuin_pty_proxy/osc133.rs899
-rw-r--r--crates/client/src/atuin_pty_proxy/pty_proxy.rs237
-rw-r--r--crates/client/src/atuin_pty_proxy/runtime.rs184
-rw-r--r--crates/client/src/atuin_pty_proxy/screen.rs96
l---------crates/client/src/command/CONTRIBUTORS1
-rw-r--r--crates/client/src/command/client.rs327
-rw-r--r--crates/client/src/command/client/config.rs352
-rw-r--r--crates/client/src/command/client/daemon.rs583
-rw-r--r--crates/client/src/command/client/default_config.rs4
-rw-r--r--crates/client/src/command/client/history.rs1136
-rw-r--r--crates/client/src/command/client/info.rs34
-rw-r--r--crates/client/src/command/client/init.rs129
-rw-r--r--crates/client/src/command/client/init/bash.rs13
-rw-r--r--crates/client/src/command/client/init/fish.rs74
-rw-r--r--crates/client/src/command/client/init/powershell.rs20
-rw-r--r--crates/client/src/command/client/init/xonsh.rs19
-rw-r--r--crates/client/src/command/client/init/zsh.rs26
-rw-r--r--crates/client/src/command/client/search.rs366
-rw-r--r--crates/client/src/command/client/search/cursor.rs404
-rw-r--r--crates/client/src/command/client/search/duration.rs63
-rw-r--r--crates/client/src/command/client/search/engines.rs86
-rw-r--r--crates/client/src/command/client/search/engines/daemon.rs213
-rw-r--r--crates/client/src/command/client/search/engines/db.rs107
-rw-r--r--crates/client/src/command/client/search/engines/skim.rs222
-rw-r--r--crates/client/src/command/client/search/history_list.rs431
-rw-r--r--crates/client/src/command/client/search/inspector.rs414
-rw-r--r--crates/client/src/command/client/search/interactive.rs3024
-rw-r--r--crates/client/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/client/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/client/src/command/client/search/keybindings/defaults.rs1274
-rw-r--r--crates/client/src/command/client/search/keybindings/key.rs633
-rw-r--r--crates/client/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/client/src/command/client/search/keybindings/mod.rs14
-rw-r--r--crates/client/src/command/client/server.rs56
-rw-r--r--crates/client/src/command/client/stats.rs82
-rw-r--r--crates/client/src/command/client/store.rs108
-rw-r--r--crates/client/src/command/client/store/pull.rs95
-rw-r--r--crates/client/src/command/client/store/purge.rs24
-rw-r--r--crates/client/src/command/client/store/push.rs113
-rw-r--r--crates/client/src/command/client/store/rebuild.rs56
-rw-r--r--crates/client/src/command/client/store/rekey.rs46
-rw-r--r--crates/client/src/command/client/store/verify.rs24
-rw-r--r--crates/client/src/command/client/sync.rs120
-rw-r--r--crates/client/src/command/client/sync/status.rs25
-rw-r--r--crates/client/src/command/client/wrapped.rs327
-rw-r--r--crates/client/src/command/contributors.rs5
-rw-r--r--crates/client/src/command/gen_completions.rs84
-rw-r--r--crates/client/src/command/mod.rs141
-rw-r--r--crates/client/src/lib.rs4
-rw-r--r--crates/client/src/main.rs76
-rw-r--r--crates/client/src/print_error.rs123
-rw-r--r--crates/client/src/shell/.gitattributes1
-rw-r--r--crates/client/src/shell/atuin.bash672
-rw-r--r--crates/client/src/shell/atuin.fish102
-rw-r--r--crates/client/src/shell/atuin.nu121
-rw-r--r--crates/client/src/shell/atuin.ps1240
-rw-r--r--crates/client/src/shell/atuin.xsh86
-rw-r--r--crates/client/src/shell/atuin.zsh167
-rw-r--r--crates/client/src/sync.rs34
92 files changed, 24358 insertions, 0 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
new file mode 100644
index 00000000..26ee6771
--- /dev/null
+++ b/crates/client/Cargo.toml
@@ -0,0 +1,103 @@
+[package]
+name = "atuin"
+edition = "2024"
+description = "atuin - magical shell history"
+readme = "./README.md"
+
+rust-version = { workspace = true }
+version = { workspace = true }
+authors = { workspace = true }
+license = { workspace = true }
+homepage = { workspace = true }
+repository = { workspace = true }
+
+[dependencies]
+async-trait = "0.1.58"
+atuin-nucleo-matcher = { workspace = true }
+atuin-nucleo = { workspace = true }
+axum = "0.8"
+base64 = "0.22"
+clap = { version = "4.5.7", features = ["derive"] }
+clap_complete = "4.5.8"
+clap_complete_nushell = "4.5.4"
+colored = "2.0.4"
+config = { version = "0.15.8", default-features = false, features = ["toml"] }
+crossterm = {version = "0.29.0", features = ["use-dev-tty", "serde"] }
+crypto_secretbox = "0.1.1"
+dashmap = "6.1.0"
+directories = "6.0.0"
+eyre = "0.6"
+fs-err = "3.1"
+fs4 = "0.13.1"
+fuzzy-matcher = "0.3.7"
+hyper-util = "0.1"
+indicatif = "0.18.0"
+interim = { version = "0.2.0", features = ["time_0_3"] }
+itertools = "0.14.0"
+lasso = { version = "0.7", features = ["multi-threaded"] }
+log = "0.4"
+metrics = "0.24"
+metrics-exporter-prometheus = { version = "0.18", default-features = false }
+minspan = "0.1.5"
+norm = { version = "0.1.1", features = ["fzf-v2"] }
+notify = "7"
+prost = "0.14"
+rand = { version = "0.8.5", features = ["std"] }
+ratatui = "0.30.0"
+regex = "1.10.5"
+reqwest = { version = "0.13", features = ["json", "rustls-no-provider", "stream"], default-features = false }
+rmp = { version = "0.8.14" }
+runtime-format = "0.1.3"
+rustix = { version = "1.1.4", features = ["process", "fs"] }
+rustls = { version = "0.23", default-features = false, features = [ "ring", "std", "tls12", ] }
+rusty_paserk = { version = "0.5.0", default-features = false, features = [ "v4", "serde", ] }
+rusty_paseto = { version = "0.8.0", default-features = false }
+semver = "1.0.20"
+serde = { version = "1.0.202", features = ["derive"] }
+serde_json = "1.0.119"
+serde_regex = "1.1.0"
+serde_with = "3.8.1"
+shellexpand = "3"
+sql-builder = "3"
+sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "time", "postgres", "uuid", "sqlite", "regexp"] }
+thiserror = "2"
+time = { version = "0.3.47", features = [ "serde-human-readable", "macros", "local-offset", "macros", "formatting", "parsing"] }
+tokio = { version = "1", features = ["full"] }
+tokio-stream = { version = "0.1.14", features = ["net"] }
+toml_edit = "0.25.4"
+tonic = "0.14"
+tonic-prost = "0.14"
+tower = "0.5"
+tower-http = { version = "0.6", features = ["trace"] }
+tracing = "0.1"
+tracing-appender = "0.2"
+tracing-subscriber = { version = "0.3", features = ["ansi", "fmt", "registry", "env-filter", "json"] }
+typed-builder = "0.18.2"
+unicode-segmentation = "1.11.0"
+unicode-width = "0.2"
+url = "2.5.2"
+uuid = { version = "1.9", features = ["v4", "v7", "serde"] }
+vt100 = "0.16"
+whoami = "2.1.0"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+arboard = { version = "3.4", default-features = false, features = [ "wayland-data-control", ] }
+listenfd = "1.0.1"
+
+[target.'cfg(unix)'.dependencies]
+daemonize = "0.5.0"
+portable-pty = "0.9"
+signal-hook = "0.3"
+
+[dev-dependencies]
+tokio = { version = "1", features = ["full"] }
+
+[build-dependencies]
+protox = "0.9"
+tonic-prost-build = "0.14"
+
+[package.metadata.docs.rs]
+all-features = true
+
+[lints]
+workspace = true
diff --git a/crates/client/build.rs b/crates/client/build.rs
new file mode 100644
index 00000000..ad4bc3c8
--- /dev/null
+++ b/crates/client/build.rs
@@ -0,0 +1,48 @@
+use std::process::Command;
+use std::{env, fs, path::PathBuf};
+
+use protox::Compiler;
+use protox::prost::Message;
+
+fn main() -> Result<(), std::io::Error> {
+ {
+ let output = Command::new("git").args(["rev-parse", "HEAD"]).output();
+
+ let sha = match output {
+ Ok(sha) => String::from_utf8(sha.stdout).unwrap(),
+ Err(_) => String::from("NO_GIT"),
+ };
+
+ println!("cargo:rustc-env=GIT_HASH={sha}");
+ }
+
+ {
+ let proto_paths = [
+ "proto/history.proto",
+ "proto/search.proto",
+ "proto/control.proto",
+ "proto/semantic.proto",
+ ];
+ let proto_include_dirs = ["proto"];
+
+ let file_descriptor_set = Compiler::new(proto_include_dirs)
+ .map_err(std::io::Error::other)?
+ .include_source_info(true)
+ .include_imports(true)
+ .open_files(proto_paths)
+ .map_err(std::io::Error::other)?
+ .file_descriptor_set();
+
+ let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"))
+ .join("file_descriptor_set.bin");
+ fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap();
+
+ tonic_prost_build::configure()
+ .build_server(true)
+ .file_descriptor_set_path(&file_descriptor_path)
+ .skip_protoc_run()
+ .compile_protos(&proto_paths, &proto_include_dirs)?;
+ }
+
+ Ok(())
+}
diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/client/src/atuin_client/api_client.rs
new file mode 100644
index 00000000..bd5bf59e
--- /dev/null
+++ b/crates/client/src/atuin_client/api_client.rs
@@ -0,0 +1,197 @@
+use std::env;
+use std::time::Duration;
+
+use eyre::{Result, bail, eyre};
+use reqwest::{Response, StatusCode, Url, header::HeaderMap};
+use tracing::debug;
+use uuid::Uuid;
+
+use crate::atuin_common::{api::ErrorResponse, record::RecordStatus};
+use crate::atuin_common::{
+ api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},
+ record::{EncryptedData, HostId, Record, RecordIdx},
+ tls::ensure_crypto_provider,
+};
+
+use semver::Version;
+
+static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
+
+pub(crate) struct Client<'a> {
+ sync_addr: &'a str,
+ user_id: Uuid,
+ inner: reqwest::Client,
+}
+
+fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> {
+ let address = address.strip_suffix('/').unwrap_or(address);
+
+ // `join()` expects a trailing `/` in order to join paths
+ // e.g. it treats `http://host:port/subdir` as a file called `subdir`
+ let address = &format!("{address}/api/v0/{user_id}/");
+
+ // passing a path with a leading `/` will cause `join()` to replace the entire URL path
+ let path = path.strip_prefix("/").unwrap_or(path);
+
+ let url = Url::parse(address)
+ .map(|url| url.join(path))?
+ .map_err(|_| eyre!("invalid address"))?;
+
+ Ok(url.to_string())
+}
+
+pub(crate) fn ensure_version(response: &Response) -> Result<bool> {
+ let version = response.headers().get(ATUIN_HEADER_VERSION);
+
+ let version = if let Some(version) = version {
+ match version.to_str() {
+ Ok(v) => Version::parse(v),
+ Err(e) => bail!("failed to parse server version: {:?}", e),
+ }
+ } else {
+ bail!("Server not reporting its version: it is either too old or unhealthy");
+ }?;
+
+ // If the client is newer than the server
+ if version.major < ATUIN_VERSION.major {
+ println!(
+ "Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"
+ );
+ println!("Client: {ATUIN_CARGO_VERSION}");
+ println!("Server: {version}");
+
+ return Ok(false);
+ }
+
+ Ok(true)
+}
+
+async fn handle_resp_error(resp: Response) -> Result<Response> {
+ let status = resp.status();
+ let url = resp.url().to_string();
+
+ if status == StatusCode::SERVICE_UNAVAILABLE {
+ bail!(
+ "Service unavailable: check https://status.atuin.sh (or get in touch with your host)"
+ );
+ }
+
+ if status == StatusCode::TOO_MANY_REQUESTS {
+ bail!("Rate limited; please wait before doing that again");
+ }
+
+ if !status.is_success() {
+ if let Ok(error) = resp.json::<ErrorResponse<'_>>().await {
+ let reason = error.reason;
+
+ if status.is_client_error() {
+ bail!("Invalid request to the service at {url}, {status} - {reason}.")
+ }
+
+ bail!(
+ "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host"
+ )
+ }
+
+ bail!(
+ "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host"
+ )
+ }
+
+ Ok(resp)
+}
+
+impl<'a> Client<'a> {
+ pub(crate) fn new(
+ sync_addr: &'a str,
+ connect_timeout: u64,
+ timeout: u64,
+ user_id: Uuid,
+ ) -> Result<Self> {
+ ensure_crypto_provider();
+ let mut headers = HeaderMap::new();
+
+ // used for semver server check
+ headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
+
+ Ok(Client {
+ user_id,
+ sync_addr,
+ inner: reqwest::Client::builder()
+ .user_agent(APP_USER_AGENT)
+ .default_headers(headers)
+ .connect_timeout(Duration::new(connect_timeout, 0))
+ .timeout(Duration::new(timeout, 0))
+ .build()?,
+ })
+ }
+
+ pub(crate) async fn delete_store(&self) -> Result<()> {
+ let url = make_url(self.sync_addr, "/store", self.user_id)?;
+ let url = Url::parse(url.as_str())?;
+
+ let resp = self.inner.delete(url).send().await?;
+
+ handle_resp_error(resp).await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn post_records(&self, records: &[Record<EncryptedData>]) -> Result<()> {
+ let url = make_url(self.sync_addr, "/record", self.user_id)?;
+ let url = Url::parse(url.as_str())?;
+
+ debug!("uploading {} records to {url}", records.len());
+
+ let resp = self.inner.post(url).json(records).send().await?;
+ handle_resp_error(resp).await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn next_records(
+ &self,
+ host: HostId,
+ tag: String,
+ start: RecordIdx,
+ count: u64,
+ ) -> Result<Vec<Record<EncryptedData>>> {
+ debug!("fetching record/s from host {}/{}/{}", host.0, tag, start);
+
+ let url = make_url(
+ self.sync_addr,
+ &format!(
+ "/record/next?host={}&tag={}&count={}&start={}",
+ host.0, tag, count, start
+ ),
+ self.user_id,
+ )?;
+
+ let url = Url::parse(url.as_str())?;
+
+ let resp = self.inner.get(url).send().await?;
+ let resp = handle_resp_error(resp).await?;
+
+ let records = resp.json::<Vec<Record<EncryptedData>>>().await?;
+
+ Ok(records)
+ }
+
+ pub(crate) async fn record_status(&self) -> Result<RecordStatus> {
+ let url = make_url(self.sync_addr, "/record", self.user_id)?;
+ let url = Url::parse(url.as_str())?;
+
+ let resp = self.inner.get(url).send().await?;
+ let resp = handle_resp_error(resp).await?;
+
+ if !ensure_version(&resp)? {
+ bail!("could not sync records due to version mismatch");
+ }
+
+ let index = resp.json().await?;
+
+ debug!("got remote index {index:?}");
+
+ Ok(index)
+ }
+}
diff --git a/crates/client/src/atuin_client/database.rs b/crates/client/src/atuin_client/database.rs
new file mode 100644
index 00000000..a9eb2058
--- /dev/null
+++ b/crates/client/src/atuin_client/database.rs
@@ -0,0 +1,1313 @@
+use std::{
+ env,
+ path::{Path, PathBuf},
+ str::FromStr,
+};
+
+use crate::{atuin_client::utils::setup_db, atuin_common::utils};
+use fs_err::{self as fs};
+use itertools::Itertools;
+use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
+use sqlx::{
+ Result, Row,
+ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow, SqliteSynchronous},
+};
+use time::OffsetDateTime;
+use tracing::debug;
+use uuid::Uuid;
+
+use crate::atuin_client::{
+ history::{HistoryId, HistoryStats},
+ utils::get_host_user,
+};
+
+use super::{
+ history::History,
+ ordering,
+ settings::{FilterMode, SearchMode, Settings},
+};
+
+#[derive(Clone)]
+pub(crate) struct Context {
+ pub(crate) session: String,
+ pub(crate) cwd: String,
+ pub(crate) hostname: String,
+ pub(crate) host_id: String,
+ pub(crate) git_root: Option<PathBuf>,
+}
+
+#[derive(Default, Clone)]
+pub(crate) struct OptFilters {
+ pub(crate) exit: Option<i64>,
+ pub(crate) exclude_exit: Option<i64>,
+ pub(crate) cwd: Option<String>,
+ pub(crate) exclude_cwd: Option<String>,
+ pub(crate) before: Option<String>,
+ pub(crate) after: Option<String>,
+ pub(crate) limit: Option<i64>,
+ pub(crate) offset: Option<i64>,
+ pub(crate) reverse: bool,
+ pub(crate) include_duplicates: bool,
+}
+
+pub(crate) async fn current_context() -> eyre::Result<Context> {
+ let session = env::var("ATUIN_SESSION").map_err(|_| {
+ eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.")
+ })?;
+ let hostname = get_host_user();
+ let cwd = utils::get_current_dir();
+ let host_id = Settings::host_id().await?;
+ let git_root = utils::in_git_repo(cwd.as_str());
+
+ Ok(Context {
+ session,
+ hostname,
+ cwd,
+ git_root,
+ host_id: host_id.0.as_simple().to_string(),
+ })
+}
+
+impl Context {
+ pub(crate) fn from_history(entry: &History) -> Self {
+ Self {
+ session: entry.session.clone(),
+ cwd: entry.cwd.clone(),
+ hostname: entry.hostname.clone(),
+ host_id: String::new(),
+ git_root: utils::in_git_repo(entry.cwd.as_str()),
+ }
+ }
+}
+
+fn get_session_start_time(session_id: &str) -> Option<i64> {
+ if let Ok(uuid) = Uuid::parse_str(session_id)
+ && let Some(timestamp) = uuid.get_timestamp()
+ {
+ let (seconds, nanos) = timestamp.to_unix();
+ return Some(seconds as i64 * 1_000_000_000 + i64::from(nanos));
+ }
+ None
+}
+
+// Intended for use on a developer machine and not a sync server.
+// TODO: implement IntoIterator
+#[derive(Debug, Clone)]
+pub(crate) struct ClientSqlite {
+ pub(crate) pool: SqlitePool,
+}
+
+impl ClientSqlite {
+ pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
+ fn mk_opts(path: &str) -> Result<SqliteConnectOptions> {
+ let opts = SqliteConnectOptions::from_str(path)?
+ .journal_mode(SqliteJournalMode::Wal)
+ .optimize_on_close(true, None)
+ .synchronous(SqliteSynchronous::Normal)
+ .with_regexp()
+ .create_if_missing(true);
+
+ Ok(opts)
+ }
+
+ let path = path.as_ref();
+ debug!("opening sqlite database at {path:?}");
+
+ if utils::broken_symlink(path) {
+ eprintln!(
+ "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
+ path.display()
+ );
+ std::process::exit(1);
+ }
+
+ if !path.exists()
+ && let Some(dir) = path.parent()
+ {
+ fs::create_dir_all(dir)?;
+ }
+
+ let pool = setup_db!(path, timeout, mk_opts, "./db/client-migrations").await?;
+ Ok(Self { pool })
+ }
+
+ async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {
+ sqlx::query(
+ "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
+ values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
+ )
+ .bind(h.id.0.as_str())
+ .bind(h.timestamp.unix_timestamp_nanos() as i64)
+ .bind(h.duration)
+ .bind(h.exit)
+ .bind(h.command.as_str())
+ .bind(h.cwd.as_str())
+ .bind(h.session.as_str())
+ .bind(h.hostname.as_str())
+ .bind(h.author.as_str())
+ .bind(h.intent.as_deref())
+ .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
+ .execute(&mut **tx)
+ .await?;
+
+ Ok(())
+ }
+
+ async fn delete_row_raw(
+ tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
+ id: HistoryId,
+ ) -> Result<()> {
+ sqlx::query("delete from history where id = ?1")
+ .bind(id.0.as_str())
+ .execute(&mut **tx)
+ .await?;
+
+ Ok(())
+ }
+
+ #[expect(clippy::needless_pass_by_value)]
+ fn query_history_inner(row: SqliteRow) -> History {
+ let deleted_at: Option<i64> = row.get("deleted_at");
+ let hostname: String = row.get("hostname");
+ let author: Option<String> = row.try_get("author").ok().flatten();
+ let author = author
+ .filter(|author| !author.trim().is_empty())
+ .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));
+ let intent: Option<String> = row.try_get("intent").ok().flatten();
+ let intent = intent.filter(|intent| !intent.trim().is_empty());
+
+ History::from_db()
+ .id(row.get("id"))
+ .timestamp(
+ OffsetDateTime::from_unix_timestamp_nanos(i128::from(
+ row.get::<i64, _>("timestamp"),
+ ))
+ .unwrap(),
+ )
+ .duration(row.get("duration"))
+ .exit(row.get("exit"))
+ .command(row.get("command"))
+ .cwd(row.get("cwd"))
+ .session(row.get("session"))
+ .hostname(hostname)
+ .author(author)
+ .intent(intent)
+ .deleted_at(
+ deleted_at
+ .and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)).ok()),
+ )
+ .build()
+ .into()
+ }
+}
+
+impl ClientSqlite {
+ pub(crate) async fn save(&self, h: &History) -> Result<()> {
+ debug!("saving history to sqlite");
+ let mut tx = self.pool.begin().await?;
+ Self::save_raw(&mut tx, h).await?;
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn save_bulk(&self, h: &[History]) -> Result<()> {
+ debug!("saving history to sqlite");
+
+ let mut tx = self.pool.begin().await?;
+
+ for i in h {
+ Self::save_raw(&mut tx, i).await?;
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn load(&self, id: &str) -> Result<Option<History>> {
+ debug!("loading history item {}", id);
+
+ let res = sqlx::query("select * from history where id = ?1")
+ .bind(id)
+ .map(Self::query_history_inner)
+ .fetch_optional(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ // make a unique list, that only shows the *newest* version of things
+ pub(crate) async fn list(
+ &self,
+ filters: &[FilterMode],
+ context: &Context,
+ max: Option<usize>,
+ unique: bool,
+ include_deleted: bool,
+ ) -> Result<Vec<History>> {
+ debug!("listing history");
+
+ let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
+ query.field("*").order_desc("timestamp");
+ if !include_deleted {
+ query.and_where_is_null("deleted_at");
+ }
+
+ let git_root = context.git_root.clone().map_or_else(
+ || context.cwd.clone(),
+ |git_root| git_root.to_str().unwrap_or("/").to_string(),
+ );
+
+ let session_start = get_session_start_time(&context.session);
+
+ for filter in filters {
+ match filter {
+ FilterMode::Global => &mut query,
+ FilterMode::Host => query.and_where_eq("hostname", quote(&context.hostname)),
+ FilterMode::Session => query.and_where_eq("session", quote(&context.session)),
+ FilterMode::SessionPreload => {
+ query.and_where_eq("session", quote(&context.session));
+ if let Some(session_start) = session_start {
+ query.or_where_lt("timestamp", session_start);
+ }
+ &mut query
+ }
+ FilterMode::Directory => query.and_where_eq("cwd", quote(&context.cwd)),
+ FilterMode::Workspace => query.and_where_like_left("cwd", &git_root),
+ };
+ }
+
+ if unique {
+ query.group_by("command").having("max(timestamp)");
+ }
+
+ if let Some(max) = max {
+ query.limit(max);
+ }
+
+ let query = query.sql().expect("bug in list query. please report");
+
+ let res = sqlx::query(&query)
+ .map(Self::query_history_inner)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) async fn range(
+ &self,
+ from: OffsetDateTime,
+ to: OffsetDateTime,
+ ) -> Result<Vec<History>> {
+ debug!("listing history from {:?} to {:?}", from, to);
+
+ let res = sqlx::query(
+ "select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc",
+ )
+ .bind(from.unix_timestamp_nanos() as i64)
+ .bind(to.unix_timestamp_nanos() as i64)
+ .map(Self::query_history_inner)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) async fn last(&self) -> Result<Option<History>> {
+ let res = sqlx::query(
+ "select * from history where duration >= 0 order by timestamp desc limit 1",
+ )
+ .map(Self::query_history_inner)
+ .fetch_optional(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) async fn history_count(&self, include_deleted: bool) -> Result<i64> {
+ let query = if include_deleted {
+ "select count(1) from history"
+ } else {
+ "select count(1) from history where deleted_at is null"
+ };
+
+ let res: (i64,) = sqlx::query_as(query).fetch_one(&self.pool).await?;
+ Ok(res.0)
+ }
+
+ // Yes I know, it's a lot.
+ // Could maybe break it down to a searchparams struct or smth but that feels a little... pointless.
+ // Been debating maybe a DSL for search? eg "before:time limit:1 the query"
+ #[expect(clippy::too_many_lines)]
+ pub(crate) async fn search(
+ &self,
+ search_mode: SearchMode,
+ filter: FilterMode,
+ context: &Context,
+ query: &str,
+ filter_options: OptFilters,
+ ) -> Result<Vec<History>> {
+ let mut sql = SqlBuilder::select_from("history");
+
+ if !filter_options.include_duplicates {
+ sql.group_by("command").having("max(timestamp)");
+ }
+
+ if let Some(limit) = filter_options.limit {
+ sql.limit(limit);
+ }
+
+ if let Some(offset) = filter_options.offset {
+ sql.offset(offset);
+ }
+
+ if filter_options.reverse {
+ sql.order_asc("timestamp");
+ } else {
+ sql.order_desc("timestamp");
+ }
+
+ let git_root = context.git_root.clone().map_or_else(
+ || context.cwd.clone(),
+ |git_root| git_root.to_str().unwrap_or("/").to_string(),
+ );
+
+ let session_start = get_session_start_time(&context.session);
+
+ match filter {
+ FilterMode::Global => &mut sql,
+ FilterMode::Host => {
+ sql.and_where_eq("lower(hostname)", quote(context.hostname.to_lowercase()))
+ }
+ FilterMode::Session => sql.and_where_eq("session", quote(&context.session)),
+ FilterMode::SessionPreload => {
+ sql.and_where_eq("session", quote(&context.session));
+ if let Some(session_start) = session_start {
+ sql.or_where_lt("timestamp", session_start);
+ }
+ &mut sql
+ }
+ FilterMode::Directory => sql.and_where_eq("cwd", quote(&context.cwd)),
+ FilterMode::Workspace => sql.and_where_like_left("cwd", git_root),
+ };
+
+ let orig_query = query;
+
+ let mut regexes = Vec::new();
+ if search_mode == SearchMode::Prefix {
+ sql.and_where_like_left("command", query.replace('*', "%"))
+ } else {
+ let mut is_or = false;
+ for token in QueryTokenizer::new(query) {
+ // TODO smart case mode could be made configurable like in fzf
+ let (is_glob, glob) = if token.has_uppercase() {
+ (true, "*")
+ } else {
+ (false, "%")
+ };
+ let param = match token {
+ QueryToken::Regex(r) => {
+ regexes.push(String::from(r));
+ continue;
+ }
+ QueryToken::Or => {
+ if !is_or {
+ is_or = true;
+ continue;
+ }
+
+ format!("{glob}|{glob}")
+ }
+ QueryToken::MatchStart(term, _) => {
+ format!("{term}{glob}")
+ }
+ QueryToken::MatchEnd(term, _) => {
+ format!("{glob}{term}")
+ }
+ QueryToken::MatchFull(term, _) => {
+ format!("{glob}{term}{glob}")
+ }
+ QueryToken::Match(term, _) => {
+ if search_mode == SearchMode::FullText {
+ format!("{glob}{term}{glob}")
+ } else {
+ term.split("").join(glob)
+ }
+ }
+ };
+
+ sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
+ is_or = false;
+ }
+
+ &mut sql
+ };
+
+ for regex in regexes {
+ sql.and_where("command regexp ?".bind(&regex));
+ }
+
+ filter_options
+ .exit
+ .map(|exit| sql.and_where_eq("exit", exit));
+
+ filter_options
+ .exclude_exit
+ .map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
+
+ filter_options
+ .cwd
+ .map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
+
+ filter_options
+ .exclude_cwd
+ .map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
+
+ filter_options.before.map(|before| {
+ interim::parse_date_string(
+ before.as_str(),
+ OffsetDateTime::now_utc(),
+ interim::Dialect::Uk,
+ )
+ .map(|before| {
+ sql.and_where_lt("timestamp", quote(before.unix_timestamp_nanos() as i64))
+ })
+ });
+
+ filter_options.after.map(|after| {
+ interim::parse_date_string(
+ after.as_str(),
+ OffsetDateTime::now_utc(),
+ interim::Dialect::Uk,
+ )
+ .map(|after| sql.and_where_gt("timestamp", quote(after.unix_timestamp_nanos() as i64)))
+ });
+
+ sql.and_where_is_null("deleted_at");
+
+ let query = sql.sql().expect("bug in search query. please report");
+
+ let res = sqlx::query(&query)
+ .map(Self::query_history_inner)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(ordering::reorder_fuzzy(search_mode, orig_query, res))
+ }
+
+ pub(crate) async fn query_history(&self, query: &str) -> Result<Vec<History>> {
+ let res = sqlx::query(query)
+ .map(Self::query_history_inner)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) async fn all_with_count(&self) -> Result<Vec<(History, i32)>> {
+ debug!("listing history");
+
+ let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
+
+ query
+ .fields(&[
+ "id",
+ "max(timestamp) as timestamp",
+ "max(duration) as duration",
+ "exit",
+ "command",
+ "deleted_at",
+ "null as author",
+ "null as intent",
+ "group_concat(cwd, ':') as cwd",
+ "group_concat(session) as session",
+ "group_concat(hostname, ',') as hostname",
+ "count(*) as count",
+ ])
+ .group_by("command")
+ .group_by("exit")
+ .and_where("deleted_at is null")
+ .order_desc("timestamp");
+
+ let query = query.sql().expect("bug in list query. please report");
+
+ let res = sqlx::query(&query)
+ .map(|row: SqliteRow| {
+ let count: i32 = row.get("count");
+ (Self::query_history_inner(row), count)
+ })
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged {
+ Paged::new(self.clone(), page_size, include_deleted, unique)
+ }
+
+ pub(crate) async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()> {
+ let mut tx = self.pool.begin().await?;
+
+ for id in ids {
+ Self::delete_row_raw(&mut tx, id.clone()).await?;
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn stats(&self, h: &History) -> Result<HistoryStats> {
+ // We select the previous in the session by time
+ let mut prev = SqlBuilder::select_from("history");
+ prev.field("*")
+ .and_where("timestamp < ?1")
+ .and_where("session = ?2")
+ .order_by("timestamp", true)
+ .limit(1);
+
+ let mut next = SqlBuilder::select_from("history");
+ next.field("*")
+ .and_where("timestamp > ?1")
+ .and_where("session = ?2")
+ .order_by("timestamp", false)
+ .limit(1);
+
+ let mut total = SqlBuilder::select_from("history");
+ total.field("count(1)").and_where("command = ?1");
+
+ let mut average = SqlBuilder::select_from("history");
+ average.field("avg(duration)").and_where("command = ?1");
+
+ let mut exits = SqlBuilder::select_from("history");
+ exits
+ .fields(&["exit", "count(1) as count"])
+ .and_where("command = ?1")
+ .group_by("exit");
+
+ // rewrite the following with sqlbuilder
+ let mut day_of_week = SqlBuilder::select_from("history");
+ day_of_week
+ .fields(&[
+ "strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week",
+ "count(1) as count",
+ ])
+ .and_where("command = ?1")
+ .group_by("day_of_week");
+
+ // Intentionally format the string with 01 hardcoded. We want the average runtime for the
+ // _entire month_, but will later parse it as a datetime for sorting
+ // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a
+ // string sort, which won't be correct.
+ let mut duration_over_time = SqlBuilder::select_from("history");
+ duration_over_time
+ .fields(&[
+ "strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year",
+ "avg(duration) as duration",
+ ])
+ .and_where("command = ?1")
+ .group_by("month_year")
+ .having("duration > 0");
+
+ let prev = prev.sql().expect("issue in stats previous query");
+ let next = next.sql().expect("issue in stats next query");
+ let total = total.sql().expect("issue in stats average query");
+ let average = average.sql().expect("issue in stats previous query");
+ let exits = exits.sql().expect("issue in stats exits query");
+ let day_of_week = day_of_week.sql().expect("issue in stats day of week query");
+ let duration_over_time = duration_over_time
+ .sql()
+ .expect("issue in stats duration over time query");
+
+ let prev = sqlx::query(&prev)
+ .bind(h.timestamp.unix_timestamp_nanos() as i64)
+ .bind(&h.session)
+ .map(Self::query_history_inner)
+ .fetch_optional(&self.pool)
+ .await?;
+
+ let next = sqlx::query(&next)
+ .bind(h.timestamp.unix_timestamp_nanos() as i64)
+ .bind(&h.session)
+ .map(Self::query_history_inner)
+ .fetch_optional(&self.pool)
+ .await?;
+
+ let total: (i64,) = sqlx::query_as(&total)
+ .bind(&h.command)
+ .fetch_one(&self.pool)
+ .await?;
+
+ let average: (f64,) = sqlx::query_as(&average)
+ .bind(&h.command)
+ .fetch_one(&self.pool)
+ .await?;
+
+ let exits: Vec<(i64, i64)> = sqlx::query_as(&exits)
+ .bind(&h.command)
+ .fetch_all(&self.pool)
+ .await?;
+
+ let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week)
+ .bind(&h.command)
+ .fetch_all(&self.pool)
+ .await?;
+
+ let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time)
+ .bind(&h.command)
+ .fetch_all(&self.pool)
+ .await?;
+
+ let duration_over_time = duration_over_time
+ .iter()
+ .map(|f| (f.0.clone(), f.1.round() as i64))
+ .collect();
+
+ Ok(HistoryStats {
+ next,
+ previous: prev,
+ total: total.0 as u64,
+ average_duration: average.0 as u64,
+ exits,
+ day_of_week,
+ duration_over_time,
+ })
+ }
+
+ pub(crate) async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>> {
+ let res = sqlx::query(
+ "SELECT * FROM (
+ SELECT *, ROW_NUMBER()
+ OVER (PARTITION BY command, cwd, hostname ORDER BY timestamp DESC)
+ AS rn
+ FROM history
+ ) sub
+ WHERE rn > ?1 and timestamp < ?2;
+ ",
+ )
+ .bind(dupkeep)
+ .bind(before)
+ .map(Self::query_history_inner)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+}
+
+pub(crate) struct Paged {
+ database: ClientSqlite,
+ page_size: usize,
+ last_id: Option<String>,
+ include_deleted: bool,
+ unique: bool,
+}
+
+impl Paged {
+ pub(crate) fn new(
+ database: ClientSqlite,
+ page_size: usize,
+ include_deleted: bool,
+ unique: bool,
+ ) -> Self {
+ Self {
+ database,
+ page_size,
+ last_id: None,
+ include_deleted,
+ unique,
+ }
+ }
+
+ pub(crate) async fn next(&mut self) -> Result<Option<Vec<History>>> {
+ let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
+
+ query.field("*").order_desc("id");
+
+ if !self.include_deleted {
+ query.and_where_is_null("deleted_at");
+ }
+
+ if self.unique {
+ // We want to deduplicate on command, but the user can search via cwd, hostname, and session.
+ // Without those fields, filter modes won't work right. With those fields, we get duplicates.
+ // This must be handled upstream.
+ query
+ .group_by("command, cwd, hostname, session")
+ .having("max(timestamp)");
+ }
+
+ query.limit(self.page_size);
+
+ if let Some(last_id) = &self.last_id {
+ query.and_where_lt("id", quote(last_id));
+ }
+
+ let query = query.sql().expect("bug in list query. please report");
+ let res = self.database.query_history(&query).await?;
+
+ if res.is_empty() {
+ Ok(None)
+ } else {
+ self.last_id = Some(res.last().unwrap().id.0.clone());
+ Ok(Some(res))
+ }
+ }
+}
+
+trait SqlBuilderExt {
+ fn fuzzy_condition<S: ToString, T: ToString>(
+ &mut self,
+ field: S,
+ mask: T,
+ inverse: bool,
+ glob: bool,
+ is_or: bool,
+ ) -> &mut Self;
+}
+
+impl SqlBuilderExt for SqlBuilder {
+ /// adapted from the sql-builder *like functions
+ fn fuzzy_condition<S: ToString, T: ToString>(
+ &mut self,
+ field: S,
+ mask: T,
+ inverse: bool,
+ glob: bool,
+ is_or: bool,
+ ) -> &mut Self {
+ let mut cond = field.to_string();
+ if inverse {
+ cond.push_str(" NOT");
+ }
+ if glob {
+ cond.push_str(" GLOB '");
+ } else {
+ cond.push_str(" LIKE '");
+ }
+ cond.push_str(&esc(mask.to_string()));
+ cond.push('\'');
+ if is_or {
+ self.or_where(cond)
+ } else {
+ self.and_where(cond)
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use crate::atuin_client::settings::test_local_timeout;
+
+ use super::{
+ ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode,
+ };
+ use std::time::{Duration, Instant};
+
+ async fn assert_search_eq(
+ db: &ClientSqlite,
+ mode: SearchMode,
+ filter_mode: FilterMode,
+ query: &str,
+ expected: usize,
+ ) -> Result<Vec<History>> {
+ let context = Context {
+ hostname: "test:host".to_string(),
+ session: "beepboopiamasession".to_string(),
+ cwd: "/home/ellie".to_string(),
+ host_id: "test-host".to_string(),
+ git_root: None,
+ };
+
+ let results = db
+ .search(
+ mode,
+ filter_mode,
+ &context,
+ query,
+ OptFilters {
+ ..Default::default()
+ },
+ )
+ .await?;
+
+ assert_eq!(
+ results.len(),
+ expected,
+ "query \"{}\", commands: {:?}",
+ query,
+ results.iter().map(|a| &a.command).collect::<Vec<&String>>()
+ );
+ Ok(results)
+ }
+
+ async fn assert_search_commands(
+ db: &ClientSqlite,
+ mode: SearchMode,
+ filter_mode: FilterMode,
+ query: &str,
+ expected_commands: Vec<&str>,
+ ) {
+ let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
+ .await
+ .unwrap();
+ let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
+ assert_eq!(commands, expected_commands);
+ }
+
+ async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> {
+ let mut captured: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command(cmd)
+ .cwd("/home/ellie")
+ .build()
+ .into();
+
+ captured.exit = 0;
+ captured.duration = 1;
+ captured.session = "beep boop".to_string();
+ captured.hostname = "booop".to_string();
+
+ db.save(&captured).await
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_search_prefix() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+ new_history_item(&mut db, "ls /home/ellie").await.unwrap();
+
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0)
+ .await
+ .unwrap();
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_search_fulltext() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+ new_history_item(&mut db, "ls /home/ellie").await.unwrap();
+
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ho", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "hm", 0)
+ .await
+ .unwrap();
+
+ // regex
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r/^ls ", 1)
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "r/ls / ie$",
+ 1,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "r/ls / !ie",
+ 0,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "meow r/ls/",
+ 0,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r//hom/", 1)
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "r//home//",
+ 1,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "r//home///",
+ 0,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home.*e", 0)
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::FullText,
+ FilterMode::Global,
+ "r/home.*e",
+ 1,
+ )
+ .await
+ .unwrap();
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_search_fuzzy() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+ new_history_item(&mut db, "ls /home/ellie").await.unwrap();
+ new_history_item(&mut db, "ls /home/frank").await.unwrap();
+ new_history_item(&mut db, "cd /home/Ellie").await.unwrap();
+ new_history_item(&mut db, "/home/ellie/.bin/rustup")
+ .await
+ .unwrap();
+
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4)
+ .await
+ .unwrap();
+
+ // single term operators
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2)
+ .await
+ .unwrap();
+
+ // multiple terms
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2)
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "'frank | 'rustup",
+ 2,
+ )
+ .await
+ .unwrap();
+ assert_search_eq(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "'frank | 'rustup 'ls",
+ 1,
+ )
+ .await
+ .unwrap();
+
+ // case matching
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1)
+ .await
+ .unwrap();
+
+ // regex
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/^ls ", 2)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/[Ee]llie", 3)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e r/^ls ", 1)
+ .await
+ .unwrap();
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_search_reordered_fuzzy() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+ // test ordering of results: we should choose the first, even though it happened longer ago.
+
+ new_history_item(&mut db, "curl").await.unwrap();
+ new_history_item(&mut db, "corburl").await.unwrap();
+
+ // if fuzzy reordering is on, it should come back in a more sensible order
+ assert_search_commands(
+ &db,
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ "curl",
+ vec!["curl", "corburl"],
+ )
+ .await;
+
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
+ .await
+ .unwrap();
+ assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2)
+ .await
+ .unwrap();
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ #[expect(clippy::similar_names)]
+ async fn test_paged_basic() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+
+ // Add 5 history items
+ for i in 0..5 {
+ new_history_item(&mut db, &format!("command{}", i))
+ .await
+ .unwrap();
+ }
+
+ // Create a paged iterator with page_size of 2
+ let mut paged = db.all_paged(2, false, false);
+
+ // First page should have 2 items
+ let page1 = paged.next().await.unwrap();
+ assert!(page1.is_some());
+ assert_eq!(page1.unwrap().len(), 2);
+
+ // Second page should have 2 items
+ let page2 = paged.next().await.unwrap();
+ assert!(page2.is_some());
+ assert_eq!(page2.unwrap().len(), 2);
+
+ // Third page should have 1 item
+ let page3 = paged.next().await.unwrap();
+ assert!(page3.is_some());
+ assert_eq!(page3.unwrap().len(), 1);
+
+ // Fourth page should be None (exhausted)
+ let page4 = paged.next().await.unwrap();
+ assert!(page4.is_none());
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_paged_empty() {
+ let db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+
+ // Create a paged iterator on empty database
+ let mut paged = db.all_paged(10, false, false);
+
+ // Should return None immediately
+ let page = paged.next().await.unwrap();
+ assert!(page.is_none());
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_paged_unique() {
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+
+ // Add duplicate commands
+ new_history_item(&mut db, "duplicate").await.unwrap();
+ new_history_item(&mut db, "duplicate").await.unwrap();
+ new_history_item(&mut db, "unique1").await.unwrap();
+ new_history_item(&mut db, "unique2").await.unwrap();
+
+ // Without unique flag - should get all 4
+ let mut paged = db.all_paged(10, false, false);
+ let page = paged.next().await.unwrap().unwrap();
+ assert_eq!(page.len(), 4);
+
+ // With unique flag - should get 3 (duplicates collapsed)
+ let mut paged_unique = db.all_paged(10, false, true);
+ let paged_unique = paged_unique.next().await.unwrap().unwrap();
+ assert_eq!(paged_unique.len(), 3);
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_search_bench_dupes() {
+ let context = Context {
+ hostname: "test:host".to_string(),
+ session: "beepboopiamasession".to_string(),
+ cwd: "/home/ellie".to_string(),
+ host_id: "test-host".to_string(),
+ git_root: None,
+ };
+
+ let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
+ .await
+ .unwrap();
+ for _i in 1..10000 {
+ new_history_item(&mut db, "i am a duplicated command")
+ .await
+ .unwrap();
+ }
+ let start = Instant::now();
+ let _results = db
+ .search(
+ SearchMode::Fuzzy,
+ FilterMode::Global,
+ &context,
+ "",
+ OptFilters {
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ let duration = start.elapsed();
+
+ assert!(duration < Duration::from_secs(15));
+ }
+}
+
+pub(crate) struct QueryTokenizer<'a> {
+ query: &'a str,
+ last_pos: usize,
+}
+
+pub(crate) enum QueryToken<'a> {
+ Match(&'a str, bool),
+ MatchStart(&'a str, bool),
+ MatchEnd(&'a str, bool),
+ MatchFull(&'a str, bool),
+ Or,
+ Regex(&'a str),
+}
+
+impl QueryToken<'_> {
+ pub(crate) fn has_uppercase(&self) -> bool {
+ match self {
+ Self::Match(term, _)
+ | Self::MatchStart(term, _)
+ | Self::MatchEnd(term, _)
+ | Self::MatchFull(term, _) => term.contains(char::is_uppercase),
+ _ => false,
+ }
+ }
+
+ pub(crate) fn is_inverse(&self) -> bool {
+ match self {
+ Self::Match(_, inv)
+ | Self::MatchStart(_, inv)
+ | Self::MatchEnd(_, inv)
+ | Self::MatchFull(_, inv) => *inv,
+ _ => false,
+ }
+ }
+}
+
+impl<'a> QueryTokenizer<'a> {
+ pub(crate) fn new(query: &'a str) -> Self {
+ Self { query, last_pos: 0 }
+ }
+}
+
+impl<'a> Iterator for QueryTokenizer<'a> {
+ type Item = QueryToken<'a>;
+ fn next(&mut self) -> Option<Self::Item> {
+ let remaining = &self.query[self.last_pos..];
+ if remaining.is_empty() {
+ return None;
+ }
+
+ if let Some(remaining) = remaining.strip_prefix("r/") {
+ let (regex, next_pos) = if let Some(end) = remaining.find("/ ") {
+ (&remaining[..end], self.last_pos + 2 + end + 2)
+ } else if let Some(remaining) = remaining.strip_suffix('/') {
+ (remaining, self.query.len())
+ } else {
+ (remaining, self.query.len())
+ };
+ self.last_pos = next_pos;
+ Some(QueryToken::Regex(regex))
+ } else {
+ let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {
+ (&remaining[..sp], self.last_pos + sp + 1)
+ } else {
+ (remaining, self.query.len())
+ };
+ self.last_pos = next_pos;
+
+ if part == "|" {
+ return Some(QueryToken::Or);
+ }
+
+ let is_inverse = part.strip_prefix('!').is_some_and(|s| {
+ part = s;
+ true
+ });
+
+ #[expect(clippy::option_if_let_else, reason = "It's too ugly")]
+ let token = if let Some(s) = part.strip_prefix('^') {
+ QueryToken::MatchStart(s, is_inverse)
+ } else if let Some(s) = part.strip_suffix('$') {
+ QueryToken::MatchEnd(s, is_inverse)
+ } else if let Some(s) = part.strip_prefix('\'') {
+ QueryToken::MatchFull(s, is_inverse)
+ } else {
+ QueryToken::Match(part, is_inverse)
+ };
+ Some(token)
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/client/src/atuin_client/encryption.rs
new file mode 100644
index 00000000..f1c921cb
--- /dev/null
+++ b/crates/client/src/atuin_client/encryption.rs
@@ -0,0 +1,142 @@
+// The general idea is that we NEVER send cleartext history to the server
+// This way the odds of anything private ending up where it should not are
+// very low
+// The server authenticates via the usual username and password. This has
+// nothing to do with the encryption, and is purely authentication! The client
+// generates its own secret key, and encrypts all shell history with libsodium's
+// secretbox. The data is then sent to the server, where it is stored. All
+// clients must share the secret in order to be able to sync, as it is needed
+// to decrypt
+
+use std::io::prelude::Write;
+
+use base64::prelude::{BASE64_STANDARD, Engine};
+pub(crate) use crypto_secretbox::Key;
+use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead::OsRng};
+use eyre::{Context, Result, bail, ensure, eyre};
+use fs_err as fs;
+use rmp::Marker;
+
+use crate::atuin_client::settings::Settings;
+
+pub(crate) fn generate_encoded_key() -> Result<(Key, String)> {
+ let key = XSalsa20Poly1305::generate_key(&mut OsRng);
+ let encoded = encode_key(&key)?;
+
+ Ok((key, encoded))
+}
+
+pub(crate) fn new_key(settings: &Settings) -> Result<Key> {
+ if settings.sync.encryption_key()?.is_some() {
+ bail!("key already exists! cannot overwrite");
+ } else if let Some(path) = settings.sync.encryption_key_path.as_ref() {
+ let (key, encoded) = generate_encoded_key()?;
+
+ let mut file = fs::File::create(path)?;
+ file.write_all(encoded.as_bytes())?;
+
+ Ok(key)
+ } else {
+ bail!("No key-path set, cannot generate key")
+ }
+}
+
+// Loads the secret key, will create + save if it doesn't exist
+pub(crate) fn load_key(settings: &Settings) -> Result<Key> {
+ if let Some(key) = settings.sync.encryption_key()? {
+ Ok(key)
+ } else {
+ Ok(new_key(settings)?)
+ }
+}
+
+pub(crate) fn encode_key(key: &Key) -> Result<String> {
+ let mut buf = vec![];
+ rmp::encode::write_array_len(&mut buf, key.len() as u32)
+ .wrap_err("could not encode key to message pack")?;
+ for b in key {
+ rmp::encode::write_uint(&mut buf, u64::from(*b))
+ .wrap_err("could not encode key to message pack")?;
+ }
+ let buf = BASE64_STANDARD.encode(buf);
+
+ Ok(buf)
+}
+
+pub(crate) fn decode_key(key: &str) -> Result<Key> {
+ use rmp::decode;
+
+ let buf = BASE64_STANDARD
+ .decode(key.trim_end())
+ .wrap_err("encryption key is not a valid base64 encoding")?;
+
+ // old code wrote the key as a fixed length array of 32 bytes
+ // new code writes the key with a length prefix
+ if let Ok(key) = <[u8; 32]>::try_from(&*buf) {
+ Ok(key.into())
+ } else {
+ let mut bytes = decode::Bytes::new(&buf);
+
+ match Marker::from_u8(buf[0]) {
+ Marker::Bin8 => {
+ let len = decode::read_bin_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
+ ensure!(len == 32, "encryption key is not the correct size");
+ let key = <[u8; 32]>::try_from(bytes.remaining_slice())
+ .context("could not decode encryption key")?;
+ Ok(key.into())
+ }
+ Marker::Array16 => {
+ let len = decode::read_array_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
+ ensure!(len == 32, "encryption key is not the correct size");
+
+ let mut key = Key::default();
+ for i in &mut key {
+ *i = decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
+ }
+ Ok(key)
+ }
+ _ => bail!("could not decode encryption key"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ #[test]
+ fn key_encodings() {
+ use super::{Key, decode_key, encode_key};
+
+ // a history of our key encodings.
+ // v11.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // v12.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // v13.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // v13.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // v14.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // v14.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
+ // c7d89c1 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/805)
+ // b53ca35 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/974)
+ // v15.0.0 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==
+ // b8b57c8 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q== (https://github.com/ellie/atuin/pull/1057)
+ // 8c94d79 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/1089)
+
+ let key = Key::from([
+ 27, 91, 42, 91, 210, 107, 9, 216, 170, 190, 242, 62, 6, 84, 69, 148, 148, 53, 251, 117,
+ 226, 167, 173, 52, 82, 34, 138, 110, 169, 124, 92, 229,
+ ]);
+
+ assert_eq!(
+ encode_key(&key).unwrap(),
+ "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q=="
+ );
+
+ // key encodings we have to support
+ let valid_encodings = [
+ "xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==",
+ "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==",
+ ];
+
+ for k in valid_encodings {
+ assert_eq!(decode_key(k).expect(k), key);
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/history.rs b/crates/client/src/atuin_client/history.rs
new file mode 100644
index 00000000..c38d8ccc
--- /dev/null
+++ b/crates/client/src/atuin_client/history.rs
@@ -0,0 +1,686 @@
+use core::fmt::Formatter;
+use rmp::decode::DecodeStringError;
+use rmp::decode::ValueReadError;
+use rmp::{Marker, decode::Bytes};
+use std::env;
+use std::fmt::Display;
+
+use crate::atuin_common::record::DecryptedData;
+use crate::atuin_common::utils::uuid_v7;
+
+use eyre::{Result, bail, eyre};
+
+use crate::atuin_client::secrets::SECRET_PATTERNS_RE;
+use crate::atuin_client::settings::Settings;
+use crate::atuin_client::utils::get_host_user;
+use time::OffsetDateTime;
+
+mod builder;
+pub(crate) mod store;
+
+pub(crate) const HISTORY_VERSION_V0: &str = "v0";
+pub(crate) const HISTORY_VERSION_V1: &str = "v1";
+const HISTORY_RECORD_VERSION_V0: u16 = 0;
+const HISTORY_RECORD_VERSION_V1: u16 = 1;
+pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;
+pub(crate) const HISTORY_TAG: &str = "history";
+const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
+const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
+
+#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+pub(crate) struct HistoryId(pub(crate) String);
+
+impl Display for HistoryId {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl From<String> for HistoryId {
+ fn from(s: String) -> Self {
+ Self(s)
+ }
+}
+
+/// Client-side history entry.
+///
+/// Client stores data unencrypted, and only encrypts it before sending to the server.
+///
+/// To create a new history entry, use one of the builders:
+/// - [`History::import()`] to import an entry from the shell history file
+/// - [`History::capture()`] to capture an entry via hook
+/// - [`History::from_db()`] to create an instance from the database entry
+//
+// ## Implementation Notes
+//
+// New fields must be added to `History::{serialize,deserialize}` in a backwards
+// compatible way (sensible defaults and careful `nfields` handling).
+#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
+pub(crate) struct History {
+ /// A client-generated ID, used to identify the entry when syncing.
+ ///
+ /// Stored as `client_id` in the database.
+ pub(crate) id: HistoryId,
+
+ /// When the command was run.
+ pub(crate) timestamp: OffsetDateTime,
+
+ /// How long the command took to run.
+ pub(crate) duration: i64,
+
+ /// The exit code of the command.
+ pub(crate) exit: i64,
+
+ /// The command that was run.
+ pub(crate) command: String,
+
+ /// The current working directory when the command was run.
+ pub(crate) cwd: String,
+
+ /// The session ID, associated with a terminal session.
+ pub(crate) session: String,
+
+ /// The hostname of the machine the command was run on.
+ pub(crate) hostname: String,
+
+ /// Who wrote this command (human user or automation/agent identity).
+ pub(crate) author: String,
+
+ /// Optional rationale for why the command was executed.
+ pub(crate) intent: Option<String>,
+
+ /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
+ pub(crate) deleted_at: Option<OffsetDateTime>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
+pub(crate) struct HistoryStats {
+ /// The command that was ran after this one in the session
+ pub(crate) next: Option<History>,
+
+ /// The command that was ran before this one in the session
+ pub(crate) previous: Option<History>,
+
+ /// How many times has this command been ran?
+ pub(crate) total: u64,
+
+ pub(crate) average_duration: u64,
+
+ pub(crate) exits: Vec<(i64, i64)>,
+
+ pub(crate) day_of_week: Vec<(String, i64)>,
+
+ pub(crate) duration_over_time: Vec<(String, i64)>,
+}
+
+impl History {
+ pub(crate) fn author_from_hostname(hostname: &str) -> String {
+ hostname
+ .split_once(':')
+ .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
+ }
+
+ fn normalize_optional_field(field: Option<String>) -> Option<String> {
+ field.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+ })
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ fn new(
+ timestamp: OffsetDateTime,
+ command: String,
+ cwd: String,
+ exit: i64,
+ duration: i64,
+ session: Option<String>,
+ hostname: Option<String>,
+ author: Option<String>,
+ intent: Option<String>,
+ deleted_at: Option<OffsetDateTime>,
+ ) -> Self {
+ let session = session
+ .or_else(|| env::var("ATUIN_SESSION").ok())
+ .unwrap_or_else(|| uuid_v7().as_simple().to_string());
+ let hostname = hostname.unwrap_or_else(get_host_user);
+ let author = Self::normalize_optional_field(author)
+ .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok()))
+ .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));
+ let intent = Self::normalize_optional_field(intent)
+ .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok()));
+
+ Self {
+ id: uuid_v7().as_simple().to_string().into(),
+ timestamp,
+ command,
+ cwd,
+ exit,
+ duration,
+ session,
+ hostname,
+ author,
+ intent,
+ deleted_at,
+ }
+ }
+
+ pub(crate) fn serialize(&self) -> Result<DecryptedData> {
+ // This is pretty much the same as what we used for the old history, with one difference -
+ // it uses integers for timestamps rather than a string format.
+
+ use rmp::encode;
+
+ let mut output = vec![];
+
+ // write the version
+ encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;
+ let include_intent = self.intent.is_some();
+ encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
+
+ encode::write_str(&mut output, &self.id.0)?;
+ encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
+ encode::write_sint(&mut output, self.duration)?;
+ encode::write_sint(&mut output, self.exit)?;
+ encode::write_str(&mut output, &self.command)?;
+ encode::write_str(&mut output, &self.cwd)?;
+ encode::write_str(&mut output, &self.session)?;
+ encode::write_str(&mut output, &self.hostname)?;
+
+ match self.deleted_at {
+ Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,
+ None => encode::write_nil(&mut output)?,
+ }
+
+ encode::write_str(&mut output, self.author.as_str())?;
+ if let Some(intent) = &self.intent {
+ encode::write_str(&mut output, intent.as_str())?;
+ }
+
+ Ok(DecryptedData(output))
+ }
+
+ fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ match decode::read_str_from_slice(bytes) {
+ Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),
+ Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
+ let mut cursor = Bytes::new(bytes);
+ decode::read_nil(&mut cursor).map_err(error_report)?;
+
+ Ok((None, cursor.remaining_slice()))
+ }
+ Err(err) => Err(error_report(err)),
+ }
+ }
+
+ fn deserialize_v0(bytes: &[u8]) -> Result<Self> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ let mut bytes = Bytes::new(bytes);
+
+ let version = decode::read_u16(&mut bytes).map_err(error_report)?;
+
+ if version != HISTORY_RECORD_VERSION_V0 {
+ bail!("expected decoding v0 record, found v{version}");
+ }
+
+ let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
+
+ if nfields != 9 {
+ bail!("cannot decrypt history from a different version of Atuin");
+ }
+
+ let bytes = bytes.remaining_slice();
+ let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+ let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
+ let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let exit = decode::read_int(&mut bytes).map_err(error_report)?;
+
+ let bytes = bytes.remaining_slice();
+ let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+
+ let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
+ Ok(unix) => (Some(unix), bytes.remaining_slice()),
+ // we accept null here
+ Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
+ Err(err) => return Err(error_report(err)),
+ };
+ if !bytes.is_empty() {
+ bail!("trailing bytes in encoded history. malformed")
+ }
+
+ Ok(Self {
+ id: id.to_owned().into(),
+ timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
+ duration,
+ exit,
+ command: command.to_owned(),
+ cwd: cwd.to_owned(),
+ session: session.to_owned(),
+ hostname: hostname.to_owned(),
+ author: Self::author_from_hostname(hostname),
+ intent: None,
+ deleted_at: deleted_at
+ .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
+ .transpose()?,
+ })
+ }
+
+ fn deserialize_v1(bytes: &[u8]) -> Result<Self> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ let mut bytes = Bytes::new(bytes);
+
+ let version = decode::read_u16(&mut bytes).map_err(error_report)?;
+
+ if version != HISTORY_RECORD_VERSION_V1 {
+ bail!("expected decoding v1 record, found v{version}");
+ }
+
+ let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
+
+ if !(10..=11).contains(&nfields) {
+ bail!("cannot decrypt history from a different version of Atuin");
+ }
+
+ let bytes = bytes.remaining_slice();
+ let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+ let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
+ let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let exit = decode::read_int(&mut bytes).map_err(error_report)?;
+
+ let bytes = bytes.remaining_slice();
+ let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+
+ let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
+ Ok(unix) => (Some(unix), bytes.remaining_slice()),
+ // we accept null here
+ Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
+ Err(err) => return Err(error_report(err)),
+ };
+ let (author, bytes) = Self::read_optional_string(bytes)?;
+ let (intent, bytes) = if nfields > 10 {
+ Self::read_optional_string(bytes)?
+ } else {
+ (None, bytes)
+ };
+
+ if !bytes.is_empty() {
+ bail!("trailing bytes in encoded history. malformed")
+ }
+
+ Ok(Self {
+ id: id.to_owned().into(),
+ timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
+ duration,
+ exit,
+ command: command.to_owned(),
+ cwd: cwd.to_owned(),
+ session: session.to_owned(),
+ hostname: hostname.to_owned(),
+ author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),
+ intent,
+ deleted_at: deleted_at
+ .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
+ .transpose()?,
+ })
+ }
+
+ pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
+ match version {
+ HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
+ HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
+
+ _ => bail!("unknown version {version:?}"),
+ }
+ }
+
+ /// Builder for a history entry that is captured via hook.
+ ///
+ /// This builder is used only at the `start` step of the hook,
+ /// so it doesn't have any fields which are known only after
+ /// the command is finished, such as `exit` or `duration`.
+ ///
+ /// ## Examples
+ /// ```rust
+ /// use crate::atuin_client::history::History;
+ ///
+ /// let history: History = History::capture()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// Command without any required info cannot be captured, which is forced at compile time:
+ ///
+ /// ```compile_fail
+ /// use crate::atuin_client::history::History;
+ ///
+ /// // this will not compile because `cwd` is missing
+ /// let history: History = History::capture()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .build()
+ /// .into();
+ /// ```
+ pub(crate) fn capture() -> builder::HistoryCapturedBuilder {
+ builder::HistoryCaptured::builder()
+ }
+
+ /// Builder for a history entry that is captured via hook, and sent to the daemon.
+ ///
+ /// This builder is used only at the `start` step of the hook,
+ /// so it doesn't have any fields which are known only after
+ /// the command is finished, such as `exit` or `duration`.
+ ///
+ /// It does, however, include information that can usually be inferred.
+ ///
+ /// This is because the daemon we are sending a request to lacks the context of the command
+ ///
+ /// ## Examples
+ /// ```rust
+ /// use crate::atuin_client::history::History;
+ ///
+ /// let history: History = History::daemon()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .session("018deb6e8287781f9973ef40e0fde76b")
+ /// .hostname("computer:ellie")
+ /// .build()
+ /// .into();
+ /// ```
+ ///
+ /// Command without any required info cannot be captured, which is forced at compile time:
+ ///
+ /// ```compile_fail
+ /// use crate::atuin_client::history::History;
+ ///
+ /// // this will not compile because `hostname` is missing
+ /// let history: History = History::daemon()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la")
+ /// .cwd("/home/user")
+ /// .session("018deb6e8287781f9973ef40e0fde76b")
+ /// .build()
+ /// .into();
+ /// ```
+ pub(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder {
+ builder::HistoryDaemonCapture::builder()
+ }
+
+ /// Builder for a history entry that is imported from the database.
+ ///
+ /// All fields are required, as they are all present in the database.
+ ///
+ /// ```compile_fail
+ /// use crate::atuin_client::history::History;
+ ///
+ /// // this will not compile because `id` field is missing
+ /// let history: History = History::from_db()
+ /// .timestamp(time::OffsetDateTime::now_utc())
+ /// .command("ls -la".to_string())
+ /// .cwd("/home/user".to_string())
+ /// .exit(0)
+ /// .duration(100)
+ /// .session("somesession".to_string())
+ /// .hostname("localhost".to_string())
+ /// .author("user".to_string())
+ /// .intent(None)
+ /// .deleted_at(None)
+ /// .build()
+ /// .into();
+ /// ```
+ pub(crate) fn from_db() -> builder::HistoryFromDbBuilder {
+ builder::HistoryFromDb::builder()
+ }
+
+ pub(crate) fn success(&self) -> bool {
+ self.exit == 0 || self.duration == -1
+ }
+
+ pub(crate) fn should_save(&self, settings: &Settings) -> bool {
+ !(self.command.is_empty()
+ || settings.history_filter.is_match(&self.command)
+ || settings.cwd_filter.is_match(&self.cwd)
+ || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use regex::RegexSet;
+ use time::macros::datetime;
+
+ use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings};
+
+ use super::History;
+
+ // Test that we don't save history where necessary
+ #[test]
+ fn privacy_test() {
+ let settings = Settings {
+ cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
+ history_filter: RegexSet::new(["^psql"]).unwrap(),
+ ..Settings::default()
+ };
+
+ let normal_command: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("echo foo")
+ .cwd("/")
+ .build()
+ .into();
+
+ let with_space: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command(" echo bar")
+ .cwd("/")
+ .build()
+ .into();
+
+ let empty: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("")
+ .cwd("/")
+ .build()
+ .into();
+
+ let stripe_key: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
+ .cwd("/")
+ .build()
+ .into();
+
+ let secret_dir: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("echo ohno")
+ .cwd("/supasecret")
+ .build()
+ .into();
+
+ let with_psql: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("psql")
+ .cwd("/supasecret")
+ .build()
+ .into();
+
+ assert!(normal_command.should_save(&settings));
+ assert!(!with_space.should_save(&settings));
+ assert!(!empty.should_save(&settings));
+ assert!(!stripe_key.should_save(&settings));
+ assert!(!secret_dir.should_save(&settings));
+ assert!(!with_psql.should_save(&settings));
+ }
+
+ #[test]
+ fn disable_secrets() {
+ let settings = Settings {
+ secrets_filter: false,
+ ..Settings::new().unwrap()
+ };
+
+ let stripe_key: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
+ .cwd("/")
+ .build()
+ .into();
+
+ assert!(stripe_key.should_save(&settings));
+ }
+
+ #[test]
+ fn test_serialize_deserialize() {
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49_206_000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
+ deleted_at: None,
+ };
+
+ let serialized = history.serialize().expect("failed to serialize history");
+ assert_eq!(
+ &serialized.0[0..3],
+ [205, 0, 1],
+ "should encode as history v1"
+ );
+
+ let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
+ .expect("failed to deserialize history");
+ assert_eq!(history, deserialized);
+ }
+
+ #[test]
+ fn test_serialize_deserialize_deleted() {
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49_206_000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
+ deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
+ };
+
+ let serialized = history.serialize().expect("failed to serialize history");
+
+ let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
+ .expect("failed to deserialize history");
+
+ assert_eq!(history, deserialized);
+ }
+
+ #[test]
+ fn test_serialize_deserialize_with_author_and_intent() {
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49_206_000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "claude".to_owned(),
+ intent: Some("check repository status".to_owned()),
+ deleted_at: None,
+ };
+
+ let serialized = history.serialize().expect("failed to serialize history");
+ let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
+ .expect("failed to deserialize history");
+
+ assert_eq!(history, deserialized);
+ }
+
+ #[test]
+ fn test_serialize_deserialize_version() {
+ // v0
+ let bytes_v0 = [
+ 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
+ 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
+ 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
+ 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
+ 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
+ 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
+ 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
+ 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
+ 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
+ ];
+
+ let deserialized = History::deserialize(&bytes_v0, "v0");
+ assert!(deserialized.is_ok());
+
+ let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
+ assert!(deserialized.is_err());
+
+ let current = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49_206_000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
+ deleted_at: None,
+ };
+
+ let bytes_v1 = current.serialize().expect("failed to serialize history");
+ let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);
+ assert!(deserialized.is_ok());
+
+ let deserialized = History::deserialize(&bytes_v1.0, "v0");
+ assert!(deserialized.is_err());
+ }
+}
diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/client/src/atuin_client/history/builder.rs
new file mode 100644
index 00000000..daa4ef49
--- /dev/null
+++ b/crates/client/src/atuin_client/history/builder.rs
@@ -0,0 +1,154 @@
+use typed_builder::TypedBuilder;
+
+use super::History;
+
+/// Builder for a history entry that is imported from shell history.
+///
+/// The only two required fields are `timestamp` and `command`.
+#[derive(Debug, Clone, TypedBuilder)]
+pub(crate) struct HistoryImported {
+ timestamp: time::OffsetDateTime,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(default = "unknown".into(), setter(into))]
+ cwd: String,
+ #[builder(default = -1)]
+ exit: i64,
+ #[builder(default = -1)]
+ duration: i64,
+ #[builder(default, setter(strip_option, into))]
+ session: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ hostname: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
+}
+
+impl From<HistoryImported> for History {
+ fn from(imported: HistoryImported) -> Self {
+ Self::new(
+ imported.timestamp,
+ imported.command,
+ imported.cwd,
+ imported.exit,
+ imported.duration,
+ imported.session,
+ imported.hostname,
+ imported.author,
+ imported.intent,
+ None,
+ )
+ }
+}
+
+/// Builder for a history entry that is captured via hook.
+///
+/// This builder is used only at the `start` step of the hook,
+/// so it doesn't have any fields which are known only after
+/// the command is finished, such as `exit` or `duration`.
+#[derive(Debug, Clone, TypedBuilder)]
+pub(crate) struct HistoryCaptured {
+ timestamp: time::OffsetDateTime,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(setter(into))]
+ cwd: String,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
+}
+
+impl From<HistoryCaptured> for History {
+ fn from(captured: HistoryCaptured) -> Self {
+ Self::new(
+ captured.timestamp,
+ captured.command,
+ captured.cwd,
+ -1,
+ -1,
+ None,
+ None,
+ captured.author,
+ captured.intent,
+ None,
+ )
+ }
+}
+
+/// Builder for a history entry that is loaded from the database.
+///
+/// All fields are required, as they are all present in the database.
+#[derive(Debug, Clone, TypedBuilder)]
+pub(crate) struct HistoryFromDb {
+ id: String,
+ timestamp: time::OffsetDateTime,
+ command: String,
+ cwd: String,
+ exit: i64,
+ duration: i64,
+ session: String,
+ hostname: String,
+ author: String,
+ intent: Option<String>,
+ deleted_at: Option<time::OffsetDateTime>,
+}
+
+impl From<HistoryFromDb> for History {
+ fn from(from_db: HistoryFromDb) -> Self {
+ Self {
+ id: from_db.id.into(),
+ timestamp: from_db.timestamp,
+ exit: from_db.exit,
+ command: from_db.command,
+ cwd: from_db.cwd,
+ duration: from_db.duration,
+ session: from_db.session,
+ hostname: from_db.hostname,
+ author: from_db.author,
+ intent: from_db.intent,
+ deleted_at: from_db.deleted_at,
+ }
+ }
+}
+
+/// Builder for a history entry that is captured via hook and sent to the daemon
+///
+/// This builder is similar to Capture, but we just require more information up front.
+/// For the old setup, we could just rely on `History::new` to read some of the missing
+/// data. This is no longer the case.
+#[derive(Debug, Clone, TypedBuilder)]
+pub(crate) struct HistoryDaemonCapture {
+ timestamp: time::OffsetDateTime,
+ #[builder(setter(into))]
+ command: String,
+ #[builder(setter(into))]
+ cwd: String,
+ #[builder(setter(into))]
+ session: String,
+ #[builder(setter(into))]
+ hostname: String,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
+}
+
+impl From<HistoryDaemonCapture> for History {
+ fn from(captured: HistoryDaemonCapture) -> Self {
+ Self::new(
+ captured.timestamp,
+ captured.command,
+ captured.cwd,
+ -1,
+ -1,
+ Some(captured.session),
+ Some(captured.hostname),
+ captured.author,
+ captured.intent,
+ None,
+ )
+ }
+}
diff --git a/crates/client/src/atuin_client/history/store.rs b/crates/client/src/atuin_client/history/store.rs
new file mode 100644
index 00000000..9c7771cc
--- /dev/null
+++ b/crates/client/src/atuin_client/history/store.rs
@@ -0,0 +1,437 @@
+use std::{collections::HashSet, fmt::Write, time::Duration};
+
+use eyre::{Result, bail, eyre};
+use indicatif::{ProgressBar, ProgressState, ProgressStyle};
+use rmp::decode::Bytes;
+use tracing::debug;
+
+use crate::atuin_client::{
+ database::{ClientSqlite, current_context},
+ record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
+};
+use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
+
+use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
+
+#[derive(Debug, Clone)]
+pub(crate) struct HistoryStore {
+ pub(crate) store: SqliteStore,
+ pub(crate) host_id: HostId,
+ pub(crate) encryption_key: [u8; 32],
+}
+
+#[derive(Debug, Eq, PartialEq, Clone)]
+pub(crate) enum HistoryRecord {
+ Create(History), // Create a history record
+ Delete(HistoryId), // Delete a history record, identified by ID
+}
+
+impl HistoryRecord {
+ /// Serialize a history record, returning `DecryptedData`
+ /// The record will be of a certain type
+ /// We map those like so:
+ ///
+ /// `HistoryRecord::Create` -> 0
+ /// `HistoryRecord::Delete`-> 1
+ ///
+ /// This numeric identifier is then written as the first byte to the buffer. For history, we
+ /// append the serialized history right afterwards, to avoid having to handle serialization
+ /// twice.
+ ///
+ /// Deletion simply refers to the history by ID
+ pub(crate) fn serialize(&self) -> Result<DecryptedData> {
+ // probably don't actually need to use rmp here, but if we ever need to extend it, it's a
+ // nice wrapper around raw byte stuff
+ use rmp::encode;
+
+ let mut output = vec![];
+
+ match self {
+ Self::Create(history) => {
+ // 0 -> a history create
+ encode::write_u8(&mut output, 0)?;
+
+ let bytes = history.serialize()?;
+
+ encode::write_bin(&mut output, &bytes.0)?;
+ }
+ Self::Delete(id) => {
+ // 1 -> a history delete
+ encode::write_u8(&mut output, 1)?;
+ encode::write_str(&mut output, id.0.as_str())?;
+ }
+ }
+
+ Ok(DecryptedData(output))
+ }
+
+ pub(crate) fn deserialize(bytes: &DecryptedData, version: &str) -> Result<Self> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ let mut bytes = Bytes::new(&bytes.0);
+
+ let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;
+
+ match record_type {
+ // 0 -> HistoryRecord::Create
+ 0 => {
+ // not super useful to us atm, but perhaps in the future
+ // written by write_bin above
+ decode::read_bin_len(&mut bytes).map_err(error_report)?;
+
+ let record = History::deserialize(bytes.remaining_slice(), version)?;
+
+ Ok(Self::Create(record))
+ }
+
+ // 1 -> HistoryRecord::Delete
+ 1 => {
+ let bytes = bytes.remaining_slice();
+ let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ if !bytes.is_empty() {
+ bail!(
+ "trailing bytes decoding HistoryRecord::Delete - malformed? got {bytes:?}"
+ );
+ }
+
+ Ok(Self::Delete(id.to_string().into()))
+ }
+
+ n => {
+ bail!("unknown HistoryRecord type {n}")
+ }
+ }
+ }
+}
+
+impl HistoryStore {
+ pub(crate) fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {
+ Self {
+ store,
+ host_id,
+ encryption_key,
+ }
+ }
+
+ async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {
+ let bytes = record.serialize()?;
+ let idx = self
+ .store
+ .last(self.host_id, HISTORY_TAG)
+ .await?
+ .map_or(0, |p| p.idx + 1);
+
+ let record = Record::builder()
+ .host(Host::new(self.host_id))
+ .version(HISTORY_VERSION.to_string())
+ .tag(HISTORY_TAG.to_string())
+ .idx(idx)
+ .data(bytes)
+ .build();
+
+ let id = record.id;
+
+ self.store
+ .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))
+ .await?;
+
+ Ok((id, idx))
+ }
+
+ async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {
+ let mut ret = Vec::new();
+
+ let idx = self
+ .store
+ .last(self.host_id, HISTORY_TAG)
+ .await?
+ .map_or(0, |p| p.idx + 1);
+
+ // Could probably _also_ do this as an iterator, but let's see how this is for now.
+ // optimizing for minimal sqlite transactions, this code can be optimised later
+ for (n, record) in records.enumerate() {
+ let bytes = record.serialize()?;
+
+ let record = Record::builder()
+ .host(Host::new(self.host_id))
+ .version(HISTORY_VERSION.to_string())
+ .tag(HISTORY_TAG.to_string())
+ .idx(idx + n as u64)
+ .data(bytes)
+ .build();
+
+ let record = record.encrypt::<PASETO_V4>(&self.encryption_key);
+
+ ret.push(record);
+ }
+
+ self.store.push_batch(ret.iter()).await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> {
+ let record = HistoryRecord::Delete(id);
+
+ self.push_record(record).await
+ }
+
+ /// Delete a batch of history entries via the record store.
+ /// Returns the record IDs so the caller can run `incremental_build` when ready.
+ pub(crate) async fn delete_entries(
+ &self,
+ entries: impl IntoIterator<Item = History>,
+ ) -> Result<Vec<RecordId>> {
+ let mut record_ids = Vec::new();
+ for entry in entries {
+ let (id, _) = self.delete(entry.id).await?;
+ record_ids.push(id);
+ }
+ Ok(record_ids)
+ }
+
+ pub(crate) async fn push(&self, history: History) -> Result<(RecordId, RecordIdx)> {
+ // TODO(ellie): move the history store to its own file
+ // it's tiny rn so fine as is
+ let record = HistoryRecord::Create(history);
+
+ self.push_record(record).await
+ }
+
+ pub(crate) async fn history(&self) -> Result<Vec<HistoryRecord>> {
+ // Atm this loads all history into memory
+ // Not ideal as that is potentially quite a lot, although history will be small.
+ let records = self.store.all_tagged(HISTORY_TAG).await?;
+ let mut ret = Vec::with_capacity(records.len());
+
+ for record in records {
+ let hist = match record.version.as_str() {
+ HISTORY_VERSION_V0 | HISTORY_VERSION => {
+ let version = record.version.clone();
+ let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
+
+ HistoryRecord::deserialize(&decrypted.data, version.as_str())
+ }
+ version => bail!("unknown history version {version:?}"),
+ }?;
+
+ ret.push(hist);
+ }
+
+ Ok(ret)
+ }
+
+ pub(crate) async fn build(&self, database: &ClientSqlite) -> Result<()> {
+ // I'd like to change how we rebuild and not couple this with the database, but need to
+ // consider the structure more deeply. This will be easy to change.
+
+ // TODO(ellie): page or iterate this
+ let history = self.history().await?;
+
+ // In theory we could flatten this here
+ // The current issue is that the database may have history in it already, from the old sync
+ // This didn't actually delete old history
+ // If we're sure we have a DB only maintained by the new store, we can flatten
+ // create/delete before we even get to sqlite
+ let mut creates = Vec::new();
+ let mut deletes = Vec::new();
+
+ for i in history {
+ match i {
+ HistoryRecord::Create(h) => {
+ creates.push(h);
+ }
+ HistoryRecord::Delete(id) => {
+ deletes.push(id);
+ }
+ }
+ }
+
+ database.save_bulk(&creates).await?;
+ database.delete_rows(&deletes).await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn incremental_build(
+ &self,
+ database: &ClientSqlite,
+ ids: &[RecordId],
+ ) -> Result<()> {
+ for id in ids {
+ let record = self.store.get(*id).await;
+
+ if let Ok(record) = record {
+ if record.tag != HISTORY_TAG {
+ continue;
+ }
+
+ let version = record.version.clone();
+ let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
+ let record = match version.as_str() {
+ HISTORY_VERSION_V0 | HISTORY_VERSION => {
+ HistoryRecord::deserialize(&decrypted.data, version.as_str())?
+ }
+ version => bail!("unknown history version {version:?}"),
+ };
+
+ match record {
+ HistoryRecord::Create(h) => {
+ // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time
+ database.save(&h).await?;
+ }
+ HistoryRecord::Delete(id) => {
+ database.delete_rows(&[id]).await?;
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Get a list of history IDs that exist in the store
+ /// Note: This currently involves loading all history into memory. This is not going to be a
+ /// large amount in absolute terms, but do not all it in a hot loop.
+ pub(crate) async fn history_ids(&self) -> Result<HashSet<HistoryId>> {
+ let history = self.history().await?;
+
+ let ret = history
+ .iter()
+ .map(|h| match h {
+ HistoryRecord::Create(h) => h.id.clone(),
+ HistoryRecord::Delete(id) => id.clone(),
+ })
+ .collect::<HashSet<_>>();
+
+ Ok(ret)
+ }
+
+ pub(crate) async fn init_store(&self, db: &ClientSqlite) -> Result<()> {
+ let pb = ProgressBar::new_spinner();
+ pb.set_style(
+ ProgressStyle::with_template("{spinner:.blue} {msg}")
+ .unwrap()
+ .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
+ write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap();
+ })
+ .progress_chars("#>-"),
+ );
+ pb.enable_steady_tick(Duration::from_millis(500));
+
+ pb.set_message("Fetching history from old database");
+
+ let context = current_context().await?;
+ let history = db.list(&[], &context, None, false, true).await?;
+
+ pb.set_message("Fetching history already in store");
+ let store_ids = self.history_ids().await?;
+
+ pb.set_message("Converting old history to new store");
+ let mut records = Vec::new();
+
+ for i in history {
+ debug!("loaded {}", i.id);
+
+ if store_ids.contains(&i.id) {
+ debug!("skipping {} - already exists", i.id);
+ continue;
+ }
+
+ if i.deleted_at.is_some() {
+ records.push(HistoryRecord::Delete(i.id));
+ } else {
+ records.push(HistoryRecord::Create(i));
+ }
+ }
+
+ pb.set_message("Writing to db");
+
+ if !records.is_empty() {
+ self.push_batch(records.into_iter()).await?;
+ }
+
+ pb.finish_with_message("Import complete");
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_common::record::DecryptedData;
+ use time::macros::datetime;
+
+ use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord};
+
+ use super::History;
+
+ #[test]
+ fn test_serialize_deserialize_create() {
+ let bytes = [
+ 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
+ 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,
+ 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,
+ 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,
+ 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,
+ 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,
+ 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,
+ 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,
+ ];
+
+ let history = History {
+ id: "018cd4fe81757cd2aee65cd7861f9c81".to_owned().into(),
+ timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),
+ duration: 100,
+ exit: 0,
+ command: "ls".to_owned(),
+ cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
+ session: "018cd4fead897597852527a31c998059".to_owned(),
+ hostname: "boop:ellie".to_owned(),
+ author: "ellie".to_owned(),
+ intent: None,
+ deleted_at: None,
+ };
+
+ let record = HistoryRecord::Create(history);
+
+ let serialized = record.serialize().expect("failed to serialize history");
+ assert_eq!(serialized.0, bytes);
+
+ let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
+ .expect("failed to deserialize HistoryRecord");
+ assert_eq!(deserialized, record);
+
+ // check the snapshot too
+ let deserialized =
+ HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
+ .expect("failed to deserialize HistoryRecord");
+ assert_eq!(deserialized, record);
+ }
+
+ #[test]
+ fn test_serialize_deserialize_delete() {
+ let bytes = [
+ 204, 1, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50,
+ 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49,
+ ];
+ let record = HistoryRecord::Delete("018cd4fe81757cd2aee65cd7861f9c81".to_string().into());
+
+ let serialized = record.serialize().expect("failed to serialize history");
+ assert_eq!(serialized.0, bytes);
+
+ let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
+ .expect("failed to deserialize HistoryRecord");
+ assert_eq!(deserialized, record);
+
+ let deserialized =
+ HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
+ .expect("failed to deserialize HistoryRecord");
+ assert_eq!(deserialized, record);
+ }
+}
diff --git a/crates/client/src/atuin_client/meta.rs b/crates/client/src/atuin_client/meta.rs
new file mode 100644
index 00000000..079c9926
--- /dev/null
+++ b/crates/client/src/atuin_client/meta.rs
@@ -0,0 +1,182 @@
+use std::path::Path;
+use std::str::FromStr;
+use std::time::Duration;
+
+use crate::atuin_common::record::HostId;
+use eyre::{Result, eyre};
+use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
+use time::{OffsetDateTime, format_description::well_known::Rfc3339};
+use tokio::sync::OnceCell;
+use tracing::debug;
+use uuid::Uuid;
+
+const KEY_HOST_ID: &str = "host_id";
+const KEY_LAST_SYNC: &str = "last_sync_time";
+
+pub(crate) struct MetaStore {
+ pool: SqlitePool,
+ cached_host_id: OnceCell<HostId>,
+}
+
+impl MetaStore {
+ pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
+ let path = path.as_ref();
+ let path_str = path
+ .as_os_str()
+ .to_str()
+ .ok_or_else(|| eyre!("meta database path is not valid UTF-8: {path:?}"))?;
+ debug!("opening meta sqlite database at {path:?}");
+
+ let is_memory = path_str.contains(":memory:");
+
+ if !is_memory
+ && !path.exists()
+ && let Some(dir) = path.parent()
+ {
+ fs_err::create_dir_all(dir)?;
+ }
+
+ // Use DELETE journal mode instead of WAL. This is a small, infrequently-
+ // written KV store — WAL's concurrency benefits aren't needed, and DELETE
+ // mode avoids creating auxiliary -wal/-shm files that complicate
+ // permission handling.
+ let opts = SqliteConnectOptions::from_str(path_str)?
+ .journal_mode(SqliteJournalMode::Delete)
+ .optimize_on_close(true, None)
+ .create_if_missing(true);
+
+ let pool = SqlitePoolOptions::new()
+ .acquire_timeout(Duration::from_secs_f64(timeout))
+ .connect_with(opts)
+ .await?;
+
+ sqlx::migrate!("./db/client-meta-migrations")
+ .run(&pool)
+ .await?;
+
+ // Session tokens are stored in this database, so restrict permissions.
+ #[cfg(unix)]
+ if !is_memory {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
+ }
+
+ let store = Self {
+ pool,
+ cached_host_id: OnceCell::const_new(),
+ };
+
+ Ok(store)
+ }
+
+ // Generic key-value operations
+
+ pub(crate) async fn get(&self, key: &str) -> Result<Option<String>> {
+ let row: Option<(String,)> = sqlx::query_as("SELECT value FROM meta WHERE key = ?1")
+ .bind(key)
+ .fetch_optional(&self.pool)
+ .await?;
+
+ Ok(row.map(|r| r.0))
+ }
+
+ pub(crate) async fn set(&self, key: &str, value: &str) -> Result<()> {
+ sqlx::query(
+ "
+ INSERT INTO meta (key, value, updated_at)
+ VALUES (?1, ?2, strftime('%s', 'now'))
+ ON CONFLICT(key) DO UPDATE
+ SET value = ?2, updated_at = strftime('%s', 'now')
+ ",
+ )
+ .bind(key)
+ .bind(value)
+ .execute(&self.pool)
+ .await?;
+
+ Ok(())
+ }
+
+ // Typed accessors
+
+ pub(crate) async fn host_id(&self) -> Result<HostId> {
+ self.cached_host_id
+ .get_or_try_init(|| async {
+ if let Some(id) = self.get(KEY_HOST_ID).await? {
+ let parsed = Uuid::from_str(id.as_str())
+ .map_err(|e| eyre!("failed to parse host ID: {e}"))?;
+ return Ok(HostId(parsed));
+ }
+
+ let uuid = crate::atuin_common::utils::uuid_v7();
+ self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
+ .await?;
+
+ Ok(HostId(uuid))
+ })
+ .await
+ .copied()
+ }
+
+ pub(crate) async fn last_sync(&self) -> Result<OffsetDateTime> {
+ match self.get(KEY_LAST_SYNC).await? {
+ Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
+ None => Ok(OffsetDateTime::UNIX_EPOCH),
+ }
+ }
+
+ pub(crate) async fn save_sync_time(&self) -> Result<()> {
+ self.set(
+ KEY_LAST_SYNC,
+ OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
+ )
+ .await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{MetaStore, OffsetDateTime};
+
+ async fn new_test_store() -> MetaStore {
+ MetaStore::new("sqlite::memory:", 2.0).await.unwrap()
+ }
+
+ #[tokio::test]
+ async fn test_get_set_delete() {
+ let store = new_test_store().await;
+
+ assert_eq!(store.get("foo").await.unwrap(), None);
+
+ store.set("foo", "bar").await.unwrap();
+ assert_eq!(store.get("foo").await.unwrap(), Some("bar".to_string()));
+
+ store.set("foo", "baz").await.unwrap();
+ assert_eq!(store.get("foo").await.unwrap(), Some("baz".to_string()));
+
+ store.delete("foo").await.unwrap();
+ assert_eq!(store.get("foo").await.unwrap(), None);
+ }
+
+ #[tokio::test]
+ async fn test_host_id_generation_and_stability() {
+ let store = new_test_store().await;
+
+ let id1 = store.host_id().await.unwrap();
+ let id2 = store.host_id().await.unwrap();
+
+ assert_eq!(id1, id2, "host_id should be stable across calls");
+ }
+
+ #[tokio::test]
+ async fn test_sync_time() {
+ let store = new_test_store().await;
+
+ let t = store.last_sync().await.unwrap();
+ assert_eq!(t, OffsetDateTime::UNIX_EPOCH);
+
+ store.save_sync_time().await.unwrap();
+ let t = store.last_sync().await.unwrap();
+ assert!(t > OffsetDateTime::UNIX_EPOCH);
+ }
+}
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs
new file mode 100644
index 00000000..851dfbdb
--- /dev/null
+++ b/crates/client/src/atuin_client/mod.rs
@@ -0,0 +1,12 @@
+pub(crate) mod api_client;
+pub(crate) mod database;
+pub(crate) mod encryption;
+pub(crate) mod history;
+pub(crate) mod meta;
+pub(crate) mod ordering;
+pub(crate) mod record;
+pub(crate) mod secrets;
+pub(crate) mod settings;
+pub(crate) mod theme;
+
+mod utils;
diff --git a/crates/client/src/atuin_client/ordering.rs b/crates/client/src/atuin_client/ordering.rs
new file mode 100644
index 00000000..84001f52
--- /dev/null
+++ b/crates/client/src/atuin_client/ordering.rs
@@ -0,0 +1,31 @@
+use minspan::minspan;
+
+use super::{history::History, settings::SearchMode};
+
+pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {
+ match mode {
+ SearchMode::Fuzzy => reorder(query, |x| &x.command, res),
+ _ => res,
+ }
+}
+
+#[expect(clippy::needless_pass_by_value, reason = "makes things easier")]
+fn reorder<F, A>(query: &str, f: F, res: Vec<A>) -> Vec<A>
+where
+ F: Fn(&A) -> &String,
+ A: Clone,
+{
+ let mut r = res.clone();
+ let qvec = &query.chars().collect();
+ r.sort_by_cached_key(|h| {
+ // TODO for fzf search we should sum up scores for each matched term
+ //
+ // The fallback is a little unfortunate: when we are asked to match a query that is found nowhere,
+ // we don't want to return a None, as the comparison behaviour would put the worst matches
+ // at the front. Therefore, we'll return a set of indices that are one larger than the longest
+ // possible legitimate match. This is meaningless except as a comparison.
+ let (from, to) = minspan::span(qvec, &(f(h).chars().collect())).unwrap_or((0, res.len()));
+ 1 + to - from
+ });
+ r
+}
diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/client/src/atuin_client/record/encryption.rs
new file mode 100644
index 00000000..d8587cf6
--- /dev/null
+++ b/crates/client/src/atuin_client/record/encryption.rs
@@ -0,0 +1,379 @@
+use crate::atuin_common::record::{
+ AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
+};
+use base64::{Engine, engine::general_purpose};
+use eyre::{Context, Result, ensure};
+use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
+use rusty_paseto::core::{
+ ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
+};
+use serde::{Deserialize, Serialize};
+
+/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.
+#[expect(non_camel_case_types)]
+pub(crate) struct PASETO_V4;
+
+/*
+Why do we use a random content-encryption key?
+Originally I was planning on using a derived key for encryption based on additional data.
+This would be a lot more secure than using the master key directly.
+
+However, there's an established norm of using a random key. This scheme might be otherwise known as
+- client-side encryption
+- envelope encryption
+- key wrapping
+
+A HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey
+will have some keys that they keep to themselves. These keys never leave their physical hardware.
+If they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.
+This is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM
+and then store that with your data.
+
+See
+ - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>
+ - <https://cloud.google.com/kms/docs/envelope-encryption>
+ - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>
+ - <https://www.yubico.com/gb/product/yubihsm-2-fips/>
+ - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>
+
+Why would we care? In the past we have received some requests for company solutions. If in future we can configure a
+KMS service with little effort, then that would solve a lot of issues for their security team.
+
+Even for personal use, if a user is not comfortable with sharing keys between hosts,
+GCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs
+1000 atuin records a day, that would only cost them $1 and 10 cent a month.
+
+Additionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.
+This makes it very fast to rotate a key in bulk.
+
+For future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting
+will need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption
+that happens in the background can make the network calls to the HSM
+*/
+
+impl Encryption for PASETO_V4 {
+ fn re_encrypt(
+ mut data: EncryptedData,
+ _ad: AdditionalData<'_>,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<EncryptedData> {
+ let cek = Self::decrypt_cek(&data.content_encryption_key, old_key)?;
+ data.content_encryption_key = Self::encrypt_cek(cek, new_key);
+ Ok(data)
+ }
+
+ fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData {
+ // generate a random key for this entry
+ // aka content-encryption-key (CEK)
+ let random_key = Key::<V4, Local>::new_os_random();
+
+ let assertions = Assertions::from(ad).encode();
+
+ // build the payload and encrypt the token
+ let payload = serde_json::to_string(&AtuinPayload {
+ data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
+ })
+ .expect("json encoding can't fail");
+ let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
+ let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
+
+ let token = Paseto::<V4, LocalPurpose>::builder()
+ .set_payload(Payload::from(payload.as_str()))
+ .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
+ .try_encrypt(&random_key.into(), &nonce)
+ .expect("error encrypting atuin data");
+
+ EncryptedData {
+ data: token,
+ content_encryption_key: Self::encrypt_cek(random_key, key),
+ }
+ }
+
+ fn decrypt(
+ data: EncryptedData,
+ ad: AdditionalData<'_>,
+ key: &[u8; 32],
+ ) -> Result<DecryptedData> {
+ let token = data.data;
+ let cek = Self::decrypt_cek(&data.content_encryption_key, key)?;
+
+ // encode the implicit assertions
+ let assertions = Assertions::from(ad).encode();
+
+ // decrypt the payload with the footer and implicit assertions
+ let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
+ &token,
+ &cek.into(),
+ None,
+ ImplicitAssertion::from(&*assertions),
+ )
+ .context("could not decrypt entry")?;
+
+ let payload: AtuinPayload = serde_json::from_str(&payload)?;
+ let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
+ Ok(DecryptedData(data))
+ }
+}
+
+impl PASETO_V4 {
+ fn decrypt_cek(wrapped_cek: &str, key: &[u8; 32]) -> Result<Key<V4, Local>> {
+ let wrapping_key = Key::<V4, Local>::from_bytes(*key);
+
+ // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));
+
+ let AtuinFooter { kid, wpk } = serde_json::from_str(wrapped_cek)
+ .context("wrapped cek did not contain the correct contents")?;
+
+ // check that the wrapping key matches the required key to decrypt.
+ // In future, we could support multiple keys and use this key to
+ // look up the key rather than only allow one key.
+ // For now though we will only support the one key and key rotation will
+ // have to be a hard reset
+ let current_kid = wrapping_key.to_id();
+
+ ensure!(
+ current_kid == kid,
+ "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
+ );
+
+ // decrypt the random key
+ Ok(wpk.unwrap_key(&wrapping_key)?)
+ }
+
+ fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
+ // aka key-encryption-key (KEK)
+ let wrapping_key = Key::<V4, Local>::from_bytes(*key);
+ let kid = wrapping_key.to_id();
+
+ // wrap the random key so we can decrypt it later
+ let wrapped_cek = AtuinFooter {
+ wpk: cek.wrap_pie(&wrapping_key),
+ kid,
+ };
+ serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+struct AtuinPayload {
+ data: String,
+}
+
+#[derive(Serialize, Deserialize)]
+/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.
+/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>
+struct AtuinFooter {
+ /// Wrapped key
+ wpk: PieWrappedKey<V4, Local>,
+ /// ID of the key which was used to wrap
+ kid: KeyId<V4, Local>,
+}
+
+/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.
+// This cannot be changed, otherwise it breaks the authenticated encryption.
+#[derive(Debug, Copy, Clone, Serialize)]
+struct Assertions<'a> {
+ id: &'a RecordId,
+ idx: &'a RecordIdx,
+ version: &'a str,
+ tag: &'a str,
+ host: &'a HostId,
+}
+
+impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
+ fn from(ad: AdditionalData<'a>) -> Self {
+ Self {
+ id: ad.id,
+ version: ad.version,
+ tag: ad.tag,
+ host: ad.host,
+ idx: ad.idx,
+ }
+ }
+}
+
+impl Assertions<'_> {
+ fn encode(&self) -> String {
+ serde_json::to_string(self).expect("could not serialize implicit assertions")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_common::{
+ record::{Host, Record},
+ utils::uuid_v7,
+ };
+
+ use super::{
+ AdditionalData, DecryptedData, Encryption, HostId, Key, Local, PASETO_V4, RecordId, V4,
+ };
+
+ #[test]
+ fn round_trip() {
+ let key = Key::<V4, Local>::new_os_random();
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ version: "v0",
+ tag: "kv",
+ host: &HostId(uuid_v7()),
+ idx: &0,
+ };
+
+ let data = DecryptedData(vec![1, 2, 3, 4]);
+
+ let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
+ let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
+ assert_eq!(decrypted, data);
+ }
+
+ #[test]
+ fn same_entry_different_output() {
+ let key = Key::<V4, Local>::new_os_random();
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ version: "v0",
+ tag: "kv",
+ host: &HostId(uuid_v7()),
+ idx: &0,
+ };
+
+ let data = DecryptedData(vec![1, 2, 3, 4]);
+
+ let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
+ let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
+
+ assert_ne!(
+ encrypted.data, encrypted2.data,
+ "re-encrypting the same contents should have different output due to key randomization"
+ );
+ }
+
+ #[test]
+ fn cannot_decrypt_different_key() {
+ let key = Key::<V4, Local>::new_os_random();
+ let fake_key = Key::<V4, Local>::new_os_random();
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ version: "v0",
+ tag: "kv",
+ host: &HostId(uuid_v7()),
+ idx: &0,
+ };
+
+ let data = DecryptedData(vec![1, 2, 3, 4]);
+
+ let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
+ drop(PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err());
+ }
+
+ #[test]
+ fn cannot_decrypt_different_id() {
+ let key = Key::<V4, Local>::new_os_random();
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ version: "v0",
+ tag: "kv",
+ host: &HostId(uuid_v7()),
+ idx: &0,
+ };
+
+ let data = DecryptedData(vec![1, 2, 3, 4]);
+
+ let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ ..ad
+ };
+ drop(PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err());
+ }
+
+ #[test]
+ fn re_encrypt_round_trip() {
+ let key1 = Key::<V4, Local>::new_os_random();
+ let key2 = Key::<V4, Local>::new_os_random();
+
+ let ad = AdditionalData {
+ id: &RecordId(uuid_v7()),
+ version: "v0",
+ tag: "kv",
+ host: &HostId(uuid_v7()),
+ idx: &0,
+ };
+
+ let data = DecryptedData(vec![1, 2, 3, 4]);
+
+ let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
+ let encrypted2 =
+ PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
+ .unwrap();
+
+ // we only re-encrypt the content keys
+ assert_eq!(encrypted1.data, encrypted2.data);
+ assert_ne!(
+ encrypted1.content_encryption_key,
+ encrypted2.content_encryption_key
+ );
+
+ let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
+
+ assert_eq!(decrypted, data);
+ }
+
+ #[test]
+ fn full_record_round_trip() {
+ let key = [0x55; 32];
+ let record = Record::builder()
+ .id(RecordId(uuid_v7()))
+ .version("v0".to_owned())
+ .tag("kv".to_owned())
+ .host(Host::new(HostId(uuid_v7())))
+ .timestamp(1_687_244_806_000_000)
+ .data(DecryptedData(vec![1, 2, 3, 4]))
+ .idx(0)
+ .build();
+
+ let encrypted = record.encrypt::<PASETO_V4>(&key);
+
+ assert!(!encrypted.data.data.is_empty());
+ assert!(!encrypted.data.content_encryption_key.is_empty());
+
+ let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
+
+ assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn full_record_round_trip_fail() {
+ let key = [0x55; 32];
+ let record = Record::builder()
+ .id(RecordId(uuid_v7()))
+ .version("v0".to_owned())
+ .tag("kv".to_owned())
+ .host(Host::new(HostId(uuid_v7())))
+ .timestamp(1_687_244_806_000_000)
+ .data(DecryptedData(vec![1, 2, 3, 4]))
+ .idx(0)
+ .build();
+
+ let encrypted = record.encrypt::<PASETO_V4>(&key);
+
+ let mut enc1 = encrypted.clone();
+ enc1.host = Host::new(HostId(uuid_v7()));
+ let _ = enc1
+ .decrypt::<PASETO_V4>(&key)
+ .expect_err("tampering with the host should result in auth failure");
+
+ let mut enc2 = encrypted;
+ enc2.id = RecordId(uuid_v7());
+ let _ = enc2
+ .decrypt::<PASETO_V4>(&key)
+ .expect_err("tampering with the id should result in auth failure");
+ }
+}
diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/client/src/atuin_client/record/mod.rs
new file mode 100644
index 00000000..4e5774ea
--- /dev/null
+++ b/crates/client/src/atuin_client/record/mod.rs
@@ -0,0 +1,3 @@
+pub(crate) mod encryption;
+pub(crate) mod sqlite_store;
+pub(crate) mod sync;
diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/client/src/atuin_client/record/sqlite_store.rs
new file mode 100644
index 00000000..18f5c869
--- /dev/null
+++ b/crates/client/src/atuin_client/record/sqlite_store.rs
@@ -0,0 +1,563 @@
+// Here we are using sqlite as a pretty dumb store, and will not be running any complex queries.
+// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index
+// by tag/host
+
+use std::path::Path;
+use std::str::FromStr;
+
+use eyre::{Result, eyre};
+use fs_err as fs;
+
+use sqlx::{
+ Row,
+ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow},
+};
+use tracing::debug;
+
+use crate::atuin_client::utils::setup_db;
+use crate::atuin_common::record::{
+ EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,
+};
+use crate::atuin_common::utils;
+use uuid::Uuid;
+
+use super::encryption::PASETO_V4;
+
+#[derive(Debug, Clone)]
+pub(crate) struct SqliteStore {
+ pool: SqlitePool,
+}
+
+impl SqliteStore {
+ pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
+ fn mk_opts(path: &str) -> sqlx::Result<SqliteConnectOptions> {
+ let opts = SqliteConnectOptions::from_str(path)?
+ .journal_mode(SqliteJournalMode::Wal)
+ .foreign_keys(true)
+ .create_if_missing(true);
+
+ Ok(opts)
+ }
+
+ let path = path.as_ref();
+
+ debug!("opening sqlite database at {path:?}");
+
+ if utils::broken_symlink(path) {
+ eprintln!(
+ "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
+ path.display()
+ );
+ std::process::exit(1);
+ }
+
+ if !path.exists()
+ && let Some(dir) = path.parent()
+ {
+ fs::create_dir_all(dir)?;
+ }
+
+ let pool = setup_db!(path, timeout, mk_opts, "./db/client-record-migrations").await?;
+
+ Ok(Self { pool })
+ }
+
+ async fn save_raw(
+ tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
+ r: &Record<EncryptedData>,
+ ) -> Result<()> {
+ // In sqlite, we are "limited" to i64. But that is still fine, until 2262.
+ sqlx::query(
+ "insert or ignore into store(id, idx, host, tag, timestamp, version, data, cek)
+ values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
+ )
+ .bind(r.id.0.as_hyphenated().to_string())
+ .bind(r.idx as i64)
+ .bind(r.host.id.0.as_hyphenated().to_string())
+ .bind(r.tag.as_str())
+ .bind(r.timestamp as i64)
+ .bind(r.version.as_str())
+ .bind(r.data.data.as_str())
+ .bind(r.data.content_encryption_key.as_str())
+ .execute(&mut **tx)
+ .await?;
+
+ Ok(())
+ }
+
+ #[expect(
+ clippy::needless_pass_by_value,
+ reason = "this is used in a place with fixed function signature"
+ )]
+ fn query_row(row: SqliteRow) -> Record<EncryptedData> {
+ let idx: i64 = row.get("idx");
+ let timestamp: i64 = row.get("timestamp");
+
+ // tbh at this point things are pretty fucked so just panic
+ let id = Uuid::from_str(row.get("id")).expect("invalid id UUID format in sqlite DB");
+ let host = Uuid::from_str(row.get("host")).expect("invalid host UUID format in sqlite DB");
+
+ Record {
+ id: RecordId(id),
+ idx: idx as u64,
+ host: Host::new(HostId(host)),
+ timestamp: timestamp as u64,
+ tag: row.get("tag"),
+ version: row.get("version"),
+ data: EncryptedData {
+ data: row.get("data"),
+ content_encryption_key: row.get("cek"),
+ },
+ }
+ }
+
+ async fn load_all(&self) -> Result<Vec<Record<EncryptedData>>> {
+ let res = sqlx::query("select * from store ")
+ .map(Self::query_row)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+}
+
+/// A record store stores records
+/// In more detail - we tend to need to process this into _another_ format to actually query it.
+/// As is, the record store is intended as the source of truth for arbitrary data, which could
+/// be shell history, kvs, etc.
+impl SqliteStore {
+ /// Push a record
+ pub(crate) async fn push(&self, record: &Record<EncryptedData>) -> Result<()> {
+ self.push_batch(std::iter::once(record)).await
+ }
+
+ /// Push a batch of records, all in one transaction
+ pub(crate) async fn push_batch(
+ &self,
+ records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,
+ ) -> Result<()> {
+ let mut tx = self.pool.begin().await?;
+
+ for record in records {
+ Self::save_raw(&mut tx, record).await?;
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>> {
+ let res = sqlx::query("select * from store where store.id = ?1")
+ .bind(id.0.as_hyphenated().to_string())
+ .map(Self::query_row)
+ .fetch_one(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ pub(crate) async fn delete(&self, id: RecordId) -> Result<()> {
+ sqlx::query("delete from store where id = ?1")
+ .bind(id.0.as_hyphenated().to_string())
+ .execute(&self.pool)
+ .await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn delete_all(&self) -> Result<()> {
+ sqlx::query("delete from store").execute(&self.pool).await?;
+
+ Ok(())
+ }
+
+ pub(crate) async fn last(
+ &self,
+ host: HostId,
+ tag: &str,
+ ) -> Result<Option<Record<EncryptedData>>> {
+ let res =
+ sqlx::query("select * from store where host=?1 and tag=?2 order by idx desc limit 1")
+ .bind(host.0.as_hyphenated().to_string())
+ .bind(tag)
+ .map(Self::query_row)
+ .fetch_one(&self.pool)
+ .await;
+
+ match res {
+ Err(sqlx::Error::RowNotFound) => Ok(None),
+ Err(e) => Err(eyre!("an error occurred: {}", e)),
+ Ok(record) => Ok(Some(record)),
+ }
+ }
+
+ pub(crate) async fn first(
+ &self,
+ host: HostId,
+ tag: &str,
+ ) -> Result<Option<Record<EncryptedData>>> {
+ self.idx(host, tag, 0).await
+ }
+
+ pub(crate) async fn len_tag(&self, tag: &str) -> Result<u64> {
+ let res: Result<(i64,), sqlx::Error> =
+ sqlx::query_as("select count(*) from store where tag=?1")
+ .bind(tag)
+ .fetch_one(&self.pool)
+ .await;
+ match res {
+ Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
+ Ok(v) => Ok(v.0 as u64),
+ }
+ }
+
+ /// Get the next `limit` records, after and including the given index
+ pub(crate) async fn next(
+ &self,
+ host: HostId,
+ tag: &str,
+ idx: RecordIdx,
+ limit: u64,
+ ) -> Result<Vec<Record<EncryptedData>>> {
+ let res = sqlx::query(
+ "select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4",
+ )
+ .bind(idx as i64)
+ .bind(host.0.as_hyphenated().to_string())
+ .bind(tag)
+ .bind(limit as i64)
+ .map(Self::query_row)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ /// Get the first record for a given host and tag
+ pub(crate) async fn idx(
+ &self,
+ host: HostId,
+ tag: &str,
+ idx: RecordIdx,
+ ) -> Result<Option<Record<EncryptedData>>> {
+ let res = sqlx::query("select * from store where idx = ?1 and host = ?2 and tag = ?3")
+ .bind(idx as i64)
+ .bind(host.0.as_hyphenated().to_string())
+ .bind(tag)
+ .map(Self::query_row)
+ .fetch_one(&self.pool)
+ .await;
+
+ match res {
+ Err(sqlx::Error::RowNotFound) => Ok(None),
+ Err(e) => Err(eyre!("an error occurred: {}", e)),
+ Ok(v) => Ok(Some(v)),
+ }
+ }
+
+ pub(crate) async fn status(&self) -> Result<RecordStatus> {
+ let mut status = RecordStatus::new();
+
+ let res: Result<Vec<(String, String, i64)>, sqlx::Error> =
+ sqlx::query_as("select host, tag, max(idx) from store group by host, tag")
+ .fetch_all(&self.pool)
+ .await;
+
+ let res = match res {
+ Err(e) => return Err(eyre!("failed to fetch local store status: {}", e)),
+ Ok(v) => v,
+ };
+
+ for i in res {
+ let host = HostId(
+ Uuid::from_str(i.0.as_str()).expect("failed to parse uuid for local store status"),
+ );
+
+ status.set_raw(host, i.1, i.2 as u64);
+ }
+
+ Ok(status)
+ }
+
+ /// Get all records for a given tag
+ pub(crate) async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>> {
+ let res = sqlx::query("select * from store where tag = ?1 order by timestamp asc")
+ .bind(tag)
+ .map(Self::query_row)
+ .fetch_all(&self.pool)
+ .await?;
+
+ Ok(res)
+ }
+
+ /// Reencrypt every single item in this store with a new key
+ /// Be careful - this may mess with sync.
+ pub(crate) async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()> {
+ // Load all the records
+ // In memory like some of the other code here
+ // This will never be called in a hot loop, and only under the following circumstances
+ // 1. The user has logged into a new account, with a new key. They are unlikely to have a
+ // lot of data
+ // 2. The user has encountered some sort of issue, and runs a maintenance command that
+ // invokes this
+ let all = self.load_all().await?;
+
+ let re_encrypted = all
+ .into_iter()
+ .map(|record| record.re_encrypt::<PASETO_V4>(old_key, new_key))
+ .collect::<Result<Vec<_>>>()?;
+
+ // next up, we delete all the old data and reinsert the new stuff
+ // do it in one transaction, so if anything fails we rollback OK
+
+ let mut tx = self.pool.begin().await?;
+
+ let res = sqlx::query("delete from store").execute(&mut *tx).await?;
+
+ let rows = res.rows_affected();
+ debug!("deleted {rows} rows");
+
+ // don't call push_batch, as it will start its own transaction
+ // call the underlying save_raw
+
+ for record in re_encrypted {
+ Self::save_raw(&mut tx, &record).await?;
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ /// Verify that every record in this store can be decrypted with the current key
+ /// Someday maybe also check each tag/record can be deserialized, but not for now.
+ pub(crate) async fn verify(&self, key: &[u8; 32]) -> Result<()> {
+ let all = self.load_all().await?;
+
+ all.into_iter()
+ .map(|record| record.decrypt::<PASETO_V4>(key))
+ .collect::<Result<Vec<_>>>()?;
+
+ Ok(())
+ }
+
+ /// Verify that every record in this store can be decrypted with the current key
+ /// Someday maybe also check each tag/record can be deserialized, but not for now.
+ pub(crate) async fn purge(&self, key: &[u8; 32]) -> Result<()> {
+ let all = self.load_all().await?;
+
+ for record in &all {
+ if record.clone().decrypt::<PASETO_V4>(key).is_ok() {
+ continue;
+ }
+
+ println!(
+ "Failed to decrypt {}, deleting",
+ record.id.0.as_hyphenated()
+ );
+
+ self.delete(record.id).await?;
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{
+ atuin_client::{
+ encryption::generate_encoded_key, record::encryption::PASETO_V4,
+ settings::test_local_timeout,
+ },
+ atuin_common::{
+ record::{DecryptedData, EncryptedData, Host, HostId, Record},
+ utils::uuid_v7,
+ },
+ };
+
+ use super::SqliteStore;
+
+ fn test_record() -> Record<EncryptedData> {
+ Record::builder()
+ .host(Host::new(HostId(uuid_v7())))
+ .version("v1".into())
+ .tag(uuid_v7().simple().to_string())
+ .data(EncryptedData {
+ data: "1234".into(),
+ content_encryption_key: "1234".into(),
+ })
+ .idx(0)
+ .build()
+ }
+
+ #[tokio::test]
+ async fn create_db() {
+ let db = SqliteStore::new(":memory:", test_local_timeout()).await;
+
+ assert!(
+ db.is_ok(),
+ "db could not be created, {:?}",
+ db.err().unwrap()
+ );
+ }
+
+ #[tokio::test]
+ async fn push_record() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+
+ db.push(&record).await.expect("failed to insert record");
+ }
+
+ #[tokio::test]
+ async fn get_record() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+ db.push(&record).await.unwrap();
+
+ let new_record = db.get(record.id).await.expect("failed to fetch record");
+
+ assert_eq!(record, new_record, "records are not equal");
+ }
+
+ #[tokio::test]
+ async fn last() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+ db.push(&record).await.unwrap();
+
+ let last = db
+ .last(record.host.id, record.tag.as_str())
+ .await
+ .expect("failed to get store len");
+
+ assert_eq!(
+ last.unwrap().id,
+ record.id,
+ "expected to get back the same record that was inserted"
+ );
+ }
+
+ #[tokio::test]
+ async fn first() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+ db.push(&record).await.unwrap();
+
+ let first = db
+ .first(record.host.id, record.tag.as_str())
+ .await
+ .expect("failed to get store len");
+
+ assert_eq!(
+ first.unwrap().id,
+ record.id,
+ "expected to get back the same record that was inserted"
+ );
+ }
+
+ #[tokio::test]
+ async fn len() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+ db.push(&record).await.unwrap();
+
+ let len = db
+ .len(record.host.id, record.tag.as_str())
+ .await
+ .expect("failed to get store len");
+
+ assert_eq!(len, 1, "expected length of 1 after insert");
+ }
+
+ #[tokio::test]
+ async fn len_tag() {
+ let db = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let record = test_record();
+ db.push(&record).await.unwrap();
+
+ let len = db
+ .len_tag(record.tag.as_str())
+ .await
+ .expect("failed to get store len");
+
+ assert_eq!(len, 1, "expected length of 1 after insert");
+ }
+
+ #[tokio::test]
+ async fn re_encrypt() {
+ let store = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .unwrap();
+ let (key, _) = generate_encoded_key().unwrap();
+ let data = vec![0u8, 1u8, 2u8, 3u8];
+ let host_id = HostId(uuid_v7());
+
+ for i in 0..10 {
+ let record = Record::builder()
+ .host(Host::new(host_id))
+ .version(String::from("test"))
+ .tag(String::from("test"))
+ .idx(i)
+ .data(DecryptedData(data.clone()))
+ .build();
+
+ let record = record.encrypt::<PASETO_V4>(&key.into());
+ store
+ .push(&record)
+ .await
+ .expect("failed to push encrypted record");
+ }
+
+ // first, check that we can decrypt the data with the current key
+ let all = store.all_tagged("test").await.unwrap();
+
+ assert_eq!(all.len(), 10, "failed to fetch all records");
+
+ for record in all {
+ let decrypted = record.decrypt::<PASETO_V4>(&key.into()).unwrap();
+ assert_eq!(decrypted.data.0, data);
+ }
+
+ // reencrypt the store, then check if
+ // 1) it cannot be decrypted with the old key
+ // 2) it can be decrypted with the new key
+
+ let (new_key, _) = generate_encoded_key().unwrap();
+ store
+ .re_encrypt(&key.into(), &new_key.into())
+ .await
+ .expect("failed to re-encrypt store");
+
+ let all = store.all_tagged("test").await.unwrap();
+
+ for record in all.iter() {
+ let decrypted = record.clone().decrypt::<PASETO_V4>(&key.into());
+ assert!(
+ decrypted.is_err(),
+ "did not get error decrypting with old key after re-encrypt"
+ )
+ }
+
+ for record in all {
+ let decrypted = record.decrypt::<PASETO_V4>(&new_key.into()).unwrap();
+ assert_eq!(decrypted.data.0, data);
+ }
+
+ assert_eq!(store.len(host_id, "test").await.unwrap(), 10);
+ }
+}
diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/client/src/atuin_client/record/sync.rs
new file mode 100644
index 00000000..da05533c
--- /dev/null
+++ b/crates/client/src/atuin_client/record/sync.rs
@@ -0,0 +1,456 @@
+// do a sync :O
+use std::{cmp::Ordering, fmt::Write};
+
+use eyre::{OptionExt, Result};
+use thiserror::Error;
+use tracing::error;
+
+use super::encryption::PASETO_V4;
+use crate::atuin_client::record::sqlite_store::SqliteStore;
+use crate::atuin_client::{api_client::Client, settings::Settings};
+
+use crate::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
+use indicatif::{ProgressBar, ProgressState, ProgressStyle};
+
+#[derive(Error, Debug)]
+pub(crate) enum SyncError {
+ #[error("an issue with the local database occurred: {msg:?}")]
+ LocalStoreError { msg: String },
+
+ #[error("something has gone wrong with the sync logic: {msg:?}")]
+ SyncLogicError { msg: String },
+
+ #[error("operational error: {msg:?}")]
+ OperationalError { msg: String },
+
+ #[error("a request to the sync server failed: {msg}")]
+ RemoteRequestError { msg: String },
+
+ #[error(
+ "the encryption key on this machine does not match the data on the server. \
+ this usually means a new machine was set up without copying the existing key. \
+ to fix: run `atuin key` on a machine that already syncs correctly, then run \
+ `atuin store rekey <key>` on this machine with the value from the other machine"
+ )]
+ WrongKey,
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) enum Operation {
+ // Either upload or download until the states matches the below
+ Upload {
+ local: RecordIdx,
+ remote: Option<RecordIdx>,
+ host: HostId,
+ tag: String,
+ },
+ Download {
+ local: Option<RecordIdx>,
+ remote: RecordIdx,
+ host: HostId,
+ tag: String,
+ },
+ Noop {
+ host: HostId,
+ tag: String,
+ },
+}
+
+pub(crate) fn build_client(settings: &Settings) -> Result<Client<'_>, SyncError> {
+ Client::new(
+ &settings.sync.address,
+ settings.network_connect_timeout,
+ settings.network_timeout,
+ settings
+ .sync
+ .user_id()
+ .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?
+ .ok_or_eyre("No sync user-id set")
+ .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,
+ )
+ .map_err(|e| SyncError::OperationalError { msg: e.to_string() })
+}
+
+pub(crate) async fn diff(
+ client: &Client<'_>,
+ store: &SqliteStore,
+) -> Result<(Vec<Diff>, RecordStatus), SyncError> {
+ let local_index = store
+ .status()
+ .await
+ .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
+
+ let remote_index = client
+ .record_status()
+ .await
+ .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
+
+ let diff = local_index.diff(&remote_index);
+
+ Ok((diff, remote_index))
+}
+
+// Take a diff, along with a local store, and resolve it into a set of operations.
+// With the store as context, we can determine if a tail exists locally or not and therefore if it needs uploading or download.
+// In theory this could be done as a part of the diffing stage, but it's easier to reason
+// about and test this way
+pub(crate) fn operations(
+ diffs: Vec<Diff>,
+ _store: &SqliteStore,
+) -> Result<Vec<Operation>, SyncError> {
+ let mut operations = Vec::with_capacity(diffs.len());
+
+ for diff in diffs {
+ let op = match (diff.local, diff.remote) {
+ // We both have it! Could be either. Compare.
+ (Some(local), Some(remote)) => match local.cmp(&remote) {
+ Ordering::Equal => Operation::Noop {
+ host: diff.host,
+ tag: diff.tag,
+ },
+ Ordering::Greater => Operation::Upload {
+ local,
+ remote: Some(remote),
+ host: diff.host,
+ tag: diff.tag,
+ },
+ Ordering::Less => Operation::Download {
+ local: Some(local),
+ remote,
+ host: diff.host,
+ tag: diff.tag,
+ },
+ },
+
+ // Remote has it, we don't. Gotta be download
+ (None, Some(remote)) => Operation::Download {
+ local: None,
+ remote,
+ host: diff.host,
+ tag: diff.tag,
+ },
+
+ // We have it, remote doesn't. Gotta be upload.
+ (Some(local), None) => Operation::Upload {
+ local,
+ remote: None,
+ host: diff.host,
+ tag: diff.tag,
+ },
+
+ // something is pretty fucked.
+ (None, None) => {
+ return Err(SyncError::SyncLogicError {
+ msg: String::from(
+ "diff has nothing for local or remote - (host, tag) does not exist",
+ ),
+ });
+ }
+ };
+
+ operations.push(op);
+ }
+
+ // sort them - purely so we have a stable testing order, and can rely on
+ // same input = same output
+ // We can sort by ID so long as we continue to use UUIDv7 or something
+ // with the same properties
+
+ operations.sort_by_key(|op| match op {
+ Operation::Noop { host, tag } => (0, *host, tag.clone()),
+
+ Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),
+
+ Operation::Download { host, tag, .. } => (2, *host, tag.clone()),
+ });
+
+ Ok(operations)
+}
+
+async fn sync_upload(
+ store: &SqliteStore,
+ client: &Client<'_>,
+ host: HostId,
+ tag: String,
+ local: RecordIdx,
+ remote: Option<RecordIdx>,
+ page_size: u64,
+) -> Result<i64, SyncError> {
+ let remote = remote.unwrap_or(0);
+ let expected = local - remote;
+ let mut progress = 0;
+
+ let pb = ProgressBar::new(expected);
+ pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
+ .unwrap()
+ .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
+ .progress_chars("#>-"));
+
+ println!(
+ "Uploading {} records to {}/{}",
+ expected,
+ host.0.as_simple(),
+ tag
+ );
+
+ loop {
+ let page = store
+ .next(host, tag.as_str(), remote + progress, page_size)
+ .await
+ .map_err(|e| {
+ error!("failed to read upload page: {e:?}");
+
+ SyncError::LocalStoreError { msg: e.to_string() }
+ })?;
+
+ if page.is_empty() {
+ break;
+ }
+
+ client.post_records(&page).await.map_err(|e| {
+ error!("failed to post records: {e:?}");
+
+ SyncError::RemoteRequestError { msg: e.to_string() }
+ })?;
+
+ progress += page.len() as u64;
+ pb.set_position(progress);
+
+ if progress >= expected {
+ break;
+ }
+ }
+
+ pb.finish_with_message("Uploaded records");
+
+ Ok(progress as i64)
+}
+
+async fn sync_download(
+ store: &SqliteStore,
+ client: &Client<'_>,
+ host: HostId,
+ tag: String,
+ local: Option<RecordIdx>,
+ remote: RecordIdx,
+ page_size: u64,
+) -> Result<Vec<RecordId>, SyncError> {
+ let local = local.unwrap_or(0);
+ let expected = remote - local;
+ let mut progress = 0;
+ let mut ret = Vec::new();
+
+ println!(
+ "Downloading {} records from {}/{}",
+ expected,
+ host.0.as_simple(),
+ tag
+ );
+
+ let pb = ProgressBar::new(expected);
+ pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
+ .unwrap()
+ .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
+ .progress_chars("#>-"));
+
+ loop {
+ let page = client
+ .next_records(host, tag.clone(), local + progress, page_size)
+ .await
+ .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
+
+ if page.is_empty() {
+ break;
+ }
+
+ store
+ .push_batch(page.iter())
+ .await
+ .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
+
+ ret.extend(page.iter().map(|f| f.id));
+
+ progress += page.len() as u64;
+ pb.set_position(progress);
+
+ if progress >= expected {
+ break;
+ }
+ }
+
+ pb.finish_with_message("Downloaded records");
+
+ Ok(ret)
+}
+
+pub(crate) async fn sync_remote(
+ client: &Client<'_>,
+ operations: Vec<Operation>,
+ local_store: &SqliteStore,
+ page_size: u64,
+) -> Result<(i64, Vec<RecordId>), SyncError> {
+ let mut uploaded = 0;
+ let mut downloaded = Vec::new();
+
+ // this can totally run in parallel, but lets get it working first
+ for i in operations {
+ match i {
+ Operation::Upload {
+ host,
+ tag,
+ local,
+ remote,
+ } => {
+ uploaded +=
+ sync_upload(local_store, client, host, tag, local, remote, page_size).await?;
+ }
+
+ Operation::Download {
+ host,
+ tag,
+ local,
+ remote,
+ } => {
+ let mut d =
+ sync_download(local_store, client, host, tag, local, remote, page_size).await?;
+ downloaded.append(&mut d);
+ }
+
+ Operation::Noop { .. } => (),
+ }
+ }
+
+ Ok((uploaded, downloaded))
+}
+
+pub(crate) async fn check_encryption_key(
+ client: &Client<'_>,
+ remote_index: &RecordStatus,
+ encryption_key: &[u8; 32],
+) -> Result<(), SyncError> {
+ let sample = remote_index
+ .hosts
+ .iter()
+ .flat_map(|(host, tags)| tags.keys().map(move |tag| (*host, tag.clone())))
+ .next();
+
+ let Some((host, tag)) = sample else {
+ return Ok(());
+ };
+
+ let records = client
+ .next_records(host, tag, 0, 1)
+ .await
+ .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
+
+ let Some(record) = records.into_iter().next() else {
+ return Ok(());
+ };
+
+ record.decrypt::<PASETO_V4>(encryption_key).map_err(|err| {
+ error!("Wrong key error: {err}");
+ SyncError::WrongKey
+ })?;
+
+ Ok(())
+}
+
+pub(crate) async fn sync(
+ settings: &Settings,
+ store: &SqliteStore,
+ encryption_key: &[u8; 32],
+) -> Result<(i64, Vec<RecordId>), SyncError> {
+ let client = build_client(settings)?;
+ let (diff, remote_index) = diff(&client, store).await?;
+
+ // Bail before mutating either side if the local key can't read the remote.
+ check_encryption_key(&client, &remote_index, encryption_key).await?;
+
+ let operations = operations(diff, store)?;
+ let (uploaded, downloaded) = sync_remote(&client, operations, store, 100).await?;
+
+ Ok((uploaded, downloaded))
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_client::record::sync::Operation;
+ use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record};
+
+ use crate::atuin_client::{
+ record::{
+ sqlite_store::SqliteStore,
+ sync::{self},
+ },
+ settings::test_local_timeout,
+ };
+
+ fn test_record() -> Record<EncryptedData> {
+ Record::builder()
+ .host(crate::atuin_common::record::Host::new(HostId(
+ crate::atuin_common::utils::uuid_v7(),
+ )))
+ .version("v1".into())
+ .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
+ .data(EncryptedData {
+ data: String::new(),
+ content_encryption_key: String::new(),
+ })
+ .idx(0)
+ .build()
+ }
+
+ // Take a list of local records, and a list of remote records.
+ // Return the local database, and a diff of local/remote, ready to build
+ // ops
+ async fn build_test_diff(
+ local_records: Vec<Record<EncryptedData>>,
+ remote_records: Vec<Record<EncryptedData>>,
+ ) -> (SqliteStore, Vec<Diff>) {
+ let local_store = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .expect("failed to open in memory sqlite");
+ let remote_store = SqliteStore::new(":memory:", test_local_timeout())
+ .await
+ .expect("failed to open in memory sqlite"); // "remote"
+
+ for i in local_records {
+ local_store.push(&i).await.unwrap();
+ }
+
+ for i in remote_records {
+ remote_store.push(&i).await.unwrap();
+ }
+
+ let local_index = local_store.status().await.unwrap();
+ let remote_index = remote_store.status().await.unwrap();
+
+ let diff = local_index.diff(&remote_index);
+
+ (local_store, diff)
+ }
+
+ #[tokio::test]
+ async fn test_basic_diff() {
+ // a diff where local is ahead of remote. nothing else.
+
+ let record = test_record();
+ let (store, diff) = build_test_diff(vec![record.clone()], vec![]).await;
+
+ assert_eq!(diff.len(), 1);
+
+ let operations = sync::operations(diff, &store).unwrap();
+
+ assert_eq!(operations.len(), 1);
+
+ assert_eq!(
+ operations[0],
+ Operation::Upload {
+ host: record.host.id,
+ tag: record.tag,
+ local: record.idx,
+ remote: None,
+ }
+ );
+ }
+}
diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/client/src/atuin_client/secrets.rs
new file mode 100644
index 00000000..74d47ea6
--- /dev/null
+++ b/crates/client/src/atuin_client/secrets.rs
@@ -0,0 +1,223 @@
+// This file will probably trigger a lot of scanners. Sorry.
+
+use regex::RegexSet;
+use std::sync::LazyLock;
+
+#[cfg(test)]
+pub(crate) enum TestValue<'a> {
+ Single(&'a str),
+ Multiple(&'a [&'a str]),
+}
+
+#[cfg(test)]
+type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)];
+
+#[cfg(not(test))]
+type SpType<'a> = &'a [(&'a str, &'a str)];
+
+/// A list of `(name, regex, test)`, where `test` should match against `regex`.
+pub(crate) static SECRET_PATTERNS: SpType<'_> = &[
+ (
+ "AWS Access Key ID",
+ "A[KS]IA[0-9A-Z]{16}",
+ #[cfg(test)]
+ TestValue::Single("AKIAIOSFODNN7EXAMPLE"),
+ ),
+ (
+ "AWS Secret Access Key env var",
+ "AWS_SECRET_ACCESS_KEY",
+ #[cfg(test)]
+ TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"),
+ ),
+ (
+ "AWS Session Token env var",
+ "AWS_SESSION_TOKEN",
+ #[cfg(test)]
+ TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"),
+ ),
+ (
+ "Microsoft Azure secret access key env var",
+ "AZURE_.*_KEY",
+ #[cfg(test)]
+ TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"),
+ ),
+ (
+ "Google cloud platform key env var",
+ "GOOGLE_SERVICE_ACCOUNT_KEY",
+ #[cfg(test)]
+ TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"),
+ ),
+ (
+ "Atuin login",
+ r"atuin\s+login",
+ #[cfg(test)]
+ TestValue::Single(
+ "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"",
+ ),
+ ),
+ (
+ "GitHub PAT (old)",
+ "ghp_[a-zA-Z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("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"), // legit, I expired it
+ ),
+ (
+ "GitHub PAT (new)",
+ "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}",
+ #[cfg(test)]
+ TestValue::Multiple(&[
+ "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm",
+ "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", // also legit, also expired
+ ]),
+ ),
+ (
+ "GitHub OAuth Access Token",
+ "gho_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
+ ),
+ (
+ "GitHub OAuth Access Token (user)",
+ "ghu_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
+ ),
+ (
+ "GitHub App Installation Access Token",
+ "ghs_[A-Za-z0-9._-]{36,}",
+ #[cfg(test)]
+ TestValue::Multiple(&[
+ "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token
+ "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data
+ ]),
+ ),
+ (
+ "GitHub Refresh Token",
+ "ghr_[A-Za-z0-9]{76}",
+ #[cfg(test)]
+ TestValue::Single(
+ "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx",
+ ), // not a real token
+ ),
+ (
+ "GitHub App Installation Access Token v1",
+ "v1\\.[0-9A-Fa-f]{40}",
+ #[cfg(test)]
+ TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token
+ ),
+ (
+ "GitLab PAT",
+ "glpat-[a-zA-Z0-9_]{20}",
+ #[cfg(test)]
+ TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"),
+ ),
+ (
+ "Slack OAuth v2 bot",
+ "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
+ ),
+ (
+ "Slack OAuth v2 user token",
+ "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
+ ),
+ (
+ "Slack webhook",
+ "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}",
+ #[cfg(test)]
+ TestValue::Single(
+ "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
+ ),
+ ),
+ (
+ "Stripe test key",
+ "sk_test_[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("sk_test_1234567890abcdefghijklmnop"),
+ ),
+ (
+ "Stripe live key",
+ "sk_live_[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("sk_live_1234567890abcdefghijklmnop"),
+ ),
+ (
+ "Netlify authentication token",
+ "nf[pcoub]_[0-9a-zA-Z]{36}",
+ #[cfg(test)]
+ TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"),
+ ),
+ (
+ "npm token",
+ "npm_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("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"),
+ ),
+ (
+ "Pulumi personal access token",
+ "pul-[0-9a-f]{40}",
+ #[cfg(test)]
+ TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"),
+ ),
+];
+
+/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`.
+pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {
+ let exprs = SECRET_PATTERNS.iter().map(|f| f.1);
+ RegexSet::new(exprs).expect("Failed to build secrets regex")
+});
+
+#[cfg(test)]
+mod tests {
+ use regex::Regex;
+
+ use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue};
+
+ #[test]
+ fn test_secrets() {
+ for (name, regex, test) in SECRET_PATTERNS {
+ let re =
+ Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
+
+ match test {
+ TestValue::Single(test) => {
+ assert!(re.is_match(test), "{name} test failed!");
+ }
+ TestValue::Multiple(tests) => {
+ for test_str in tests.iter() {
+ assert!(
+ re.is_match(test_str),
+ "{name} test with value \"{test_str}\" failed!"
+ );
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_secrets_embedded() {
+ for (name, regex, test) in SECRET_PATTERNS {
+ let re =
+ Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
+
+ match test {
+ TestValue::Single(test) => {
+ let embedded = format!("some random text {test} some more random text");
+ assert!(re.is_match(&embedded), "{name} embedded test failed!");
+ }
+ TestValue::Multiple(tests) => {
+ for test_str in tests.iter() {
+ let embedded = format!("some random text {test_str} some more random text");
+ assert!(
+ re.is_match(&embedded),
+ "{name} embedded test with value \"{test_str}\" failed!"
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/settings.rs b/crates/client/src/atuin_client/settings.rs
new file mode 100644
index 00000000..9e14c4c8
--- /dev/null
+++ b/crates/client/src/atuin_client/settings.rs
@@ -0,0 +1,1543 @@
+use crypto_secretbox::Key;
+use std::{
+ collections::HashMap, fmt, fs::read_to_string, path::PathBuf, str::FromStr, sync::OnceLock,
+};
+use tokio::sync::OnceCell;
+use tracing::info;
+use uuid::Uuid;
+
+use crate::atuin_common::utils;
+use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId};
+use clap::ValueEnum;
+use config::{
+ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
+};
+use eyre::{Context, Error, Result, bail, eyre};
+use fs_err::create_dir_all;
+use regex::RegexSet;
+use serde::{Deserialize, Serialize};
+use serde_with::DeserializeFromStr;
+use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
+
+static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
+static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
+static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::const_new();
+
+pub(crate) mod meta;
+pub(crate) mod watcher;
+
+#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
+pub(crate) enum SearchMode {
+ #[serde(rename = "prefix")]
+ Prefix,
+
+ #[serde(rename = "fulltext")]
+ #[clap(aliases = &["fulltext"])]
+ FullText,
+
+ #[serde(rename = "fuzzy")]
+ Fuzzy,
+
+ #[serde(rename = "skim")]
+ Skim,
+
+ #[serde(rename = "daemon-fuzzy")]
+ #[clap(aliases = &["daemon-fuzzy"])]
+ DaemonFuzzy,
+}
+
+impl SearchMode {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Prefix => "PREFIX",
+ Self::FullText => "FULLTXT",
+ Self::Fuzzy => "FUZZY",
+ Self::Skim => "SKIM",
+ Self::DaemonFuzzy => "DAEMON",
+ }
+ }
+ pub(crate) fn next(self, settings: &Settings) -> Self {
+ match self {
+ Self::Prefix => Self::FullText,
+ // if the user is using skim, we go to skim
+ Self::FullText if settings.search_mode == Self::Skim => Self::Skim,
+ // if the user is using daemon-fuzzy, we go to daemon-fuzzy
+ Self::FullText if settings.search_mode == Self::DaemonFuzzy => Self::DaemonFuzzy,
+ // otherwise fuzzy.
+ Self::FullText => Self::Fuzzy,
+ Self::Fuzzy | Self::Skim | Self::DaemonFuzzy => Self::Prefix,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum FilterMode {
+ #[serde(rename = "global")]
+ Global = 0,
+
+ #[serde(rename = "host")]
+ Host = 1,
+
+ #[serde(rename = "session")]
+ Session = 2,
+
+ #[serde(rename = "directory")]
+ Directory = 3,
+
+ #[serde(rename = "workspace")]
+ Workspace = 4,
+
+ #[serde(rename = "session-preload")]
+ SessionPreload = 5,
+}
+
+impl FilterMode {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Global => "GLOBAL",
+ Self::Host => "HOST",
+ Self::Session => "SESSION",
+ Self::Directory => "DIRECTORY",
+ Self::Workspace => "WORKSPACE",
+ Self::SessionPreload => "SESSION+",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum ExitMode {
+ #[serde(rename = "return-original")]
+ ReturnOriginal,
+
+ #[serde(rename = "return-query")]
+ ReturnQuery,
+}
+
+// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
+// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum Dialect {
+ #[serde(rename = "us")]
+ Us,
+
+ #[serde(rename = "uk")]
+ Uk,
+}
+
+impl From<Dialect> for interim::Dialect {
+ fn from(d: Dialect) -> Self {
+ match d {
+ Dialect::Uk => Self::Uk,
+ Dialect::Us => Self::Us,
+ }
+ }
+}
+
+/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
+///
+/// Note that the parsing of this struct needs to be done before starting any
+/// multithreaded runtime, otherwise it will fail on most Unix systems.
+///
+/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>
+#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
+pub(crate) struct Timezone(pub(crate) UtcOffset);
+impl fmt::Display for Timezone {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+/// format: <+|-><hour>[:<minute>[:<second>]]
+static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
+ "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
+);
+impl FromStr for Timezone {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self> {
+ // local timezone
+ if matches!(s.to_lowercase().as_str(), "l" | "local") {
+ // There have been some timezone issues, related to errors fetching it on some
+ // platforms
+ // Rather than fail to start, fallback to UTC. The user should still be able to specify
+ // their timezone manually in the config file.
+ let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+ return Ok(Self(offset));
+ }
+
+ if matches!(s.to_lowercase().as_str(), "0" | "utc") {
+ let offset = UtcOffset::UTC;
+ return Ok(Self(offset));
+ }
+
+ // offset from UTC
+ if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
+ return Ok(Self(offset));
+ }
+
+ // IDEA: Currently named timezones are not supported, because the well-known crate
+ // for this is `chrono_tz`, which is not really interoperable with the datetime crate
+ // that we currently use - `time`. If ever we migrate to using `chrono`, this would
+ // be a good feature to add.
+
+ bail!(r#""{s}" is not a valid timezone spec"#)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum Style {
+ #[serde(rename = "auto")]
+ Auto,
+
+ #[serde(rename = "full")]
+ Full,
+
+ #[serde(rename = "compact")]
+ Compact,
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
+pub(crate) enum WordJumpMode {
+ #[serde(rename = "emacs")]
+ Emacs,
+
+ #[serde(rename = "subl")]
+ Subl,
+}
+
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum KeymapMode {
+ #[serde(rename = "emacs")]
+ Emacs,
+
+ #[serde(rename = "vim-normal")]
+ VimNormal,
+
+ #[serde(rename = "vim-insert")]
+ VimInsert,
+
+ #[serde(rename = "auto")]
+ Auto,
+}
+
+// We want to translate the config to crossterm::cursor::SetCursorStyle, but
+// the original type does not implement trait serde::Deserialize unfortunately.
+// It seems impossible to implement Deserialize for external types when it is
+// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
+// define an adapter type.
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum CursorStyle {
+ #[serde(rename = "default")]
+ DefaultUserShape,
+
+ #[serde(rename = "blink-block")]
+ BlinkingBlock,
+
+ #[serde(rename = "steady-block")]
+ SteadyBlock,
+
+ #[serde(rename = "blink-underline")]
+ BlinkingUnderScore,
+
+ #[serde(rename = "steady-underline")]
+ SteadyUnderScore,
+
+ #[serde(rename = "blink-bar")]
+ BlinkingBar,
+
+ #[serde(rename = "steady-bar")]
+ SteadyBar,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Stats {
+ #[serde(default = "Stats::common_prefix_default")]
+ pub(crate) common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
+ #[serde(default = "Stats::common_subcommands_default")]
+ pub(crate) common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
+ #[serde(default = "Stats::ignored_commands_default")]
+ pub(crate) ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
+}
+
+impl Stats {
+ fn common_prefix_default() -> Vec<String> {
+ vec!["sudo", "doas"].into_iter().map(String::from).collect()
+ }
+
+ fn common_subcommands_default() -> Vec<String> {
+ vec![
+ "apt",
+ "cargo",
+ "composer",
+ "dnf",
+ "docker",
+ "dotnet",
+ "git",
+ "go",
+ "ip",
+ "jj",
+ "kubectl",
+ "nix",
+ "nmcli",
+ "npm",
+ "pecl",
+ "pnpm",
+ "podman",
+ "port",
+ "systemctl",
+ "tmux",
+ "yarn",
+ ]
+ .into_iter()
+ .map(String::from)
+ .collect()
+ }
+
+ fn ignored_commands_default() -> Vec<String> {
+ vec![]
+ }
+}
+
+impl Default for Stats {
+ fn default() -> Self {
+ Self {
+ common_prefix: Self::common_prefix_default(),
+ common_subcommands: Self::common_subcommands_default(),
+ ignored_commands: Self::ignored_commands_default(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Default, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
+pub(crate) struct Keys {
+ pub(crate) scroll_exits: bool,
+ pub(crate) exit_past_line_start: bool,
+ pub(crate) accept_past_line_end: bool,
+ pub(crate) accept_past_line_start: bool,
+ pub(crate) accept_with_backspace: bool,
+ pub(crate) prefix: String,
+}
+
+impl Keys {
+ /// The standard default values for all `[keys]` options.
+ /// These match the config defaults set in `builder_with_data_dir()`.
+ pub(crate) fn standard_defaults() -> Self {
+ Self {
+ scroll_exits: true,
+ exit_past_line_start: true,
+ accept_past_line_end: true,
+ accept_past_line_start: false,
+ accept_with_backspace: false,
+ prefix: "a".to_string(),
+ }
+ }
+}
+
+/// A single rule within a conditional keybinding config.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct KeyRuleConfig {
+ /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results").
+ /// If absent, the rule always matches.
+ #[serde(default)]
+ pub(crate) when: Option<String>,
+ /// The action to perform (e.g. "exit", "cursor-left", "accept").
+ pub(crate) action: String,
+}
+
+/// A keybinding config value: either a simple action string or an ordered list of conditional rules.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub(crate) enum KeyBindingConfig {
+ /// Simple unconditional binding: `"ctrl-c" = "return-original"`
+ Simple(String),
+ /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]`
+ Rules(Vec<KeyRuleConfig>),
+}
+
+/// User-facing keymap configuration. Each mode maps key strings to bindings.
+/// Keys present here override the defaults for that key; unmentioned keys keep defaults.
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub(crate) struct KeymapConfig {
+ #[serde(default)]
+ pub(crate) emacs: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-normal")]
+ pub(crate) vim_normal: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-insert")]
+ pub(crate) vim_insert: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub(crate) inspector: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub(crate) prefix: HashMap<String, KeyBindingConfig>,
+}
+
+impl KeymapConfig {
+ /// Returns true if no keybinding overrides are configured in any mode.
+ pub(crate) fn is_empty(&self) -> bool {
+ self.emacs.is_empty()
+ && self.vim_normal.is_empty()
+ && self.vim_insert.is_empty()
+ && self.inspector.is_empty()
+ && self.prefix.is_empty()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Preview {
+ pub(crate) strategy: PreviewStrategy,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Daemon {
+ /// The daemon will handle sync on an interval. How often to sync, in seconds.
+ pub(crate) sync_frequency: u64,
+
+ /// The path to the unix socket used by the daemon
+ pub(crate) socket_path: String,
+
+ /// Path to the daemon pidfile used for process coordination.
+ pub(crate) pidfile_path: String,
+
+ /// Use a socket passed via systemd's socket activation protocol, instead of the path
+ pub(crate) systemd_socket: bool,
+
+ /// The port that should be used for TCP on non unix systems
+ pub(crate) tcp_port: u64,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Search {
+ /// The list of enabled filter modes, in order of priority.
+ pub(crate) filters: Vec<FilterMode>,
+
+ /// The recency score multiplier for the search index (default: 1.0).
+ /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
+ pub(crate) recency_score_multiplier: f64,
+
+ /// The frequency score multiplier for the search index (default: 1.0).
+ /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
+ pub(crate) frequency_score_multiplier: f64,
+
+ /// The overall frecency score multiplier for the search index (default: 1.0).
+ /// Applied after combining recency and frequency scores.
+ pub(crate) frecency_score_multiplier: f64,
+}
+
+/// Log level for file logging. Maps to tracing's [`LevelFilter`].
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum LogLevel {
+ Trace,
+ Debug,
+ #[default]
+ Info,
+ Warn,
+ Error,
+}
+
+impl LogLevel {
+ /// Convert to a tracing directive string for use with [`EnvFilter`].
+ pub(crate) fn as_directive(self) -> &'static str {
+ match self {
+ Self::Trace => "trace",
+ Self::Debug => "debug",
+ Self::Info => "info",
+ Self::Warn => "warn",
+ Self::Error => "error",
+ }
+ }
+}
+
+/// Configuration for a specific log type (search or daemon).
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub(crate) struct LogConfig {
+ /// Log file name (relative to dir) or absolute path.
+ pub(crate) file: String,
+
+ /// Override global enabled setting for this log type.
+ pub(crate) enabled: Option<bool>,
+
+ /// Override global level setting for this log type.
+ pub(crate) level: Option<LogLevel>,
+
+ /// Override global retention days setting for this log type.
+ pub(crate) retention: Option<u64>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Logs {
+ /// Enable file logging globally. Defaults to true.
+ #[serde(default = "Logs::default_enabled")]
+ pub(crate) enabled: bool,
+
+ /// Directory for log files. Defaults to ~/.atuin/logs
+ pub(crate) dir: String,
+
+ /// Default log level for file logging. Defaults to "info".
+ /// Note: [`ATUIN_LOG`] environment variable overrides this.
+ #[serde(default)]
+ pub(crate) level: LogLevel,
+
+ /// Default retention days for log files. Defaults to 4.
+ #[serde(default = "Logs::default_retention")]
+ pub(crate) retention: u64,
+
+ /// Search log settings
+ #[serde(default)]
+ pub(crate) search: LogConfig,
+
+ /// Daemon log settings
+ #[serde(default)]
+ pub(crate) daemon: LogConfig,
+}
+
+impl Default for Preview {
+ fn default() -> Self {
+ Self {
+ strategy: PreviewStrategy::Auto,
+ }
+ }
+}
+
+impl Default for Daemon {
+ fn default() -> Self {
+ Self {
+ sync_frequency: 300,
+ socket_path: String::new(),
+ pidfile_path: String::new(),
+ systemd_socket: false,
+ tcp_port: 8889,
+ }
+ }
+}
+
+impl Default for Logs {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ dir: String::new(),
+ level: LogLevel::default(),
+ retention: Self::default_retention(),
+ search: LogConfig {
+ file: "search.log".to_string(),
+ ..Default::default()
+ },
+ daemon: LogConfig {
+ file: "daemon.log".to_string(),
+ ..Default::default()
+ },
+ }
+ }
+}
+
+impl Logs {
+ fn default_enabled() -> bool {
+ true
+ }
+
+ fn default_retention() -> u64 {
+ 4
+ }
+
+ /// Returns whether search logging is enabled.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_enabled(&self) -> bool {
+ self.search.enabled.unwrap_or(self.enabled)
+ }
+
+ /// Returns whether daemon logging is enabled.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_enabled(&self) -> bool {
+ self.daemon.enabled.unwrap_or(self.enabled)
+ }
+
+ /// Returns the log level for search logging.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_level(&self) -> LogLevel {
+ self.search.level.unwrap_or(self.level)
+ }
+
+ /// Returns the log level for daemon logging.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_level(&self) -> LogLevel {
+ self.daemon.level.unwrap_or(self.level)
+ }
+
+ /// Returns the retention days for search logging.
+ /// Uses search-specific setting if set, otherwise falls back to global.
+ pub(crate) fn search_retention(&self) -> u64 {
+ self.search.retention.unwrap_or(self.retention)
+ }
+
+ /// Returns the retention days for daemon logging.
+ /// Uses daemon-specific setting if set, otherwise falls back to global.
+ pub(crate) fn daemon_retention(&self) -> u64 {
+ self.daemon.retention.unwrap_or(self.retention)
+ }
+}
+
+impl Default for Search {
+ fn default() -> Self {
+ Self {
+ filters: vec![
+ FilterMode::Global,
+ FilterMode::Host,
+ FilterMode::Session,
+ FilterMode::SessionPreload,
+ FilterMode::Workspace,
+ FilterMode::Directory,
+ ],
+
+ recency_score_multiplier: 1.0,
+ frequency_score_multiplier: 1.0,
+ frecency_score_multiplier: 1.0,
+ }
+ }
+}
+
+// The preview height strategy also takes max_preview_height into account.
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum PreviewStrategy {
+ // Preview height is calculated for the length of the selected command.
+ #[serde(rename = "auto")]
+ Auto,
+
+ // Preview height is calculated for the length of the longest command stored in the history.
+ #[serde(rename = "static")]
+ Static,
+
+ // max_preview_height is used as fixed height.
+ #[serde(rename = "fixed")]
+ Fixed,
+}
+
+/// Column types available for the interactive search UI.
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub(crate) enum UiColumnType {
+ /// Command execution duration (e.g., "123ms")
+ Duration,
+ /// Relative time since execution (e.g., "59s ago")
+ Time,
+ /// Absolute timestamp (e.g., "2025-01-22 14:35")
+ Datetime,
+ /// Working directory
+ Directory,
+ /// Hostname
+ Host,
+ /// Username
+ User,
+ /// Exit code
+ Exit,
+ /// The command itself (should be last, expands to fill)
+ Command,
+}
+
+impl UiColumnType {
+ /// Returns the default width for this column type (in characters).
+ /// The Command column returns 0 as it expands to fill remaining space.
+ pub(crate) fn default_width(self) -> u16 {
+ match self {
+ Self::Duration => 5, // "814ms"
+ Self::Time => 9, // "459ms ago"
+ Self::Datetime => 16, // "2025-01-22 14:35"
+ Self::Directory => 20,
+ Self::Host => 15,
+ Self::User => 10,
+ Self::Exit => {
+ if cfg!(windows) {
+ 11 // 32-bit integer on Windows: "-1978335212"
+ } else {
+ 3 // Usually a byte on Unix
+ }
+ }
+ Self::Command => 0, // Expands to fill
+ }
+ }
+}
+
+/// A column configuration with type and optional custom width.
+/// Can be specified as just a string (uses default width) or as an object with type and width.
+#[derive(Clone, Debug, Serialize)]
+pub(crate) struct UiColumn {
+ pub(crate) column_type: UiColumnType,
+ pub(crate) width: u16,
+ /// If true, this column expands to fill remaining space. Only one column should expand.
+ pub(crate) expand: bool,
+}
+
+impl UiColumn {
+ pub(crate) fn new(column_type: UiColumnType) -> Self {
+ Self {
+ width: column_type.default_width(),
+ expand: column_type == UiColumnType::Command,
+ column_type,
+ }
+ }
+}
+
+// Custom deserialize to handle both string and object formats:
+// "duration" or { type = "duration", width = 8, expand = true }
+impl<'de> Deserialize<'de> for UiColumn {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ use serde::de::{self, MapAccess, Visitor};
+
+ struct UiColumnVisitor;
+
+ impl<'de> Visitor<'de> for UiColumnVisitor {
+ type Value = UiColumn;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(
+ "a column type string or an object with 'type' and optional 'width'/'expand'",
+ )
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>
+ where
+ E: de::Error,
+ {
+ let column_type: UiColumnType =
+ Deserialize::deserialize(de::value::StrDeserializer::new(value))?;
+ Ok(UiColumn::new(column_type))
+ }
+
+ fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>
+ where
+ M: MapAccess<'de>,
+ {
+ let mut column_type: Option<UiColumnType> = None;
+ let mut width: Option<u16> = None;
+ let mut expand: Option<bool> = None;
+
+ while let Some(key) = map.next_key::<String>()? {
+ match key.as_str() {
+ "type" => {
+ column_type = Some(map.next_value()?);
+ }
+ "width" => {
+ width = Some(map.next_value()?);
+ }
+ "expand" => {
+ expand = Some(map.next_value()?);
+ }
+ _ => {
+ let _: de::IgnoredAny = map.next_value()?;
+ }
+ }
+ }
+
+ let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?;
+ let width = width.unwrap_or_else(|| column_type.default_width());
+ let expand = expand.unwrap_or(column_type == UiColumnType::Command);
+ Ok(UiColumn {
+ column_type,
+ width,
+ expand,
+ })
+ }
+ }
+
+ deserializer.deserialize_any(UiColumnVisitor)
+ }
+}
+
+/// UI-specific settings for the interactive search.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Ui {
+ /// Columns to display in interactive search, from left to right.
+ /// The indicator column (" > ") is always shown first implicitly.
+ /// The "command" column should be last as it expands to fill remaining space.
+ /// Can be simple strings or objects with type and width.
+ #[serde(default = "Ui::default_columns")]
+ pub(crate) columns: Vec<UiColumn>,
+}
+
+impl Ui {
+ fn default_columns() -> Vec<UiColumn> {
+ vec![
+ UiColumn::new(UiColumnType::Duration),
+ UiColumn::new(UiColumnType::Time),
+ UiColumn::new(UiColumnType::Command),
+ ]
+ }
+
+ /// Validate the UI configuration.
+ /// Returns an error if more than one column has expand = true.
+ pub(crate) fn validate(&self) -> Result<()> {
+ let expand_count = self.columns.iter().filter(|c| c.expand).count();
+ if expand_count > 1 {
+ bail!(
+ "Only one column can have expand = true, but {} columns are set to expand",
+ expand_count
+ );
+ }
+ Ok(())
+ }
+}
+
+impl Default for Ui {
+ fn default() -> Self {
+ Self {
+ columns: Self::default_columns(),
+ }
+ }
+}
+
+/// Sync-specific settings.
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub(crate) struct Sync {
+ /// The sync address for atuin.
+ pub(crate) address: String,
+
+ #[serde(default)]
+ pub(crate) frequency: String,
+
+ #[serde(default)]
+ pub(crate) auto: bool,
+
+ #[serde(default)]
+ pub(crate) user_id_path: Option<PathBuf>,
+
+ #[serde(default)]
+ pub(crate) encryption_key_path: Option<PathBuf>,
+}
+
+impl Sync {
+ fn try_read_file(file: Option<&PathBuf>) -> Result<Option<String>> {
+ if let Some(path) = file {
+ if path.try_exists()? {
+ let user = read_to_string(path)?;
+
+ if user.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(user))
+ }
+ } else {
+ // It's okay that the file doesn't exist.
+ // The important part is to error out if we can't access it (e.g. Because of missing
+ // permissions).
+ Ok(None)
+ }
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub(crate) fn have_sync_user(&self) -> Result<bool> {
+ let sa = self.user_id()?;
+ Ok(sa.is_some())
+ }
+
+ pub(crate) fn user_id(&self) -> Result<Option<Uuid>> {
+ Self::try_read_file(self.user_id_path.as_ref())?
+ .map(|file| {
+ Uuid::parse_str(file.trim()).context(
+ "Failed to decode user id as UUID, while trying to decode sync user_id",
+ )
+ })
+ .transpose()
+ }
+ pub(crate) fn encryption_key(&self) -> Result<Option<Key>> {
+ Self::try_read_file(self.encryption_key_path.as_ref())?
+ .as_deref()
+ .map(str::trim)
+ .map(decode_key)
+ .transpose()
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
+pub(crate) struct Settings {
+ pub(crate) data_dir: Option<String>,
+ pub(crate) dialect: Dialect,
+ pub(crate) timezone: Timezone,
+ pub(crate) style: Style,
+
+ pub(crate) db_path: String,
+ pub(crate) record_store_path: String,
+ pub(crate) search_mode: SearchMode,
+ pub(crate) filter_mode: Option<FilterMode>,
+ pub(crate) filter_mode_shell_up_key_binding: Option<FilterMode>,
+ pub(crate) search_mode_shell_up_key_binding: Option<SearchMode>,
+ pub(crate) shell_up_key_binding: bool,
+ pub(crate) inline_height: u16,
+ pub(crate) inline_height_shell_up_key_binding: Option<u16>,
+ pub(crate) invert: bool,
+ pub(crate) show_preview: bool,
+ pub(crate) max_preview_height: u16,
+ pub(crate) show_help: bool,
+ pub(crate) show_tabs: bool,
+ pub(crate) show_numeric_shortcuts: bool,
+ pub(crate) auto_hide_height: u16,
+ pub(crate) exit_mode: ExitMode,
+ pub(crate) keymap_mode: KeymapMode,
+ pub(crate) keymap_mode_shell: KeymapMode,
+ pub(crate) keymap_cursor: HashMap<String, CursorStyle>,
+ pub(crate) word_jump_mode: WordJumpMode,
+ pub(crate) word_chars: String,
+ pub(crate) scroll_context_lines: usize,
+ pub(crate) history_format: String,
+ pub(crate) strip_trailing_whitespace: bool,
+ pub(crate) prefers_reduced_motion: bool,
+ pub(crate) store_failed: bool,
+ pub(crate) no_mouse: bool,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) history_filter: RegexSet,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) cwd_filter: RegexSet,
+
+ pub(crate) secrets_filter: bool,
+ pub(crate) workspaces: bool,
+ pub(crate) ctrl_n_shortcuts: bool,
+
+ pub(crate) network_connect_timeout: u64,
+ pub(crate) network_timeout: u64,
+ pub(crate) local_timeout: f64,
+ pub(crate) enter_accept: bool,
+ pub(crate) smart_sort: bool,
+ pub(crate) command_chaining: bool,
+
+ #[serde(default)]
+ pub(crate) sync: Sync,
+
+ #[serde(default)]
+ pub(crate) stats: Stats,
+
+ #[serde(default)]
+ pub(crate) keys: Keys,
+
+ #[serde(default)]
+ pub(crate) keymap: KeymapConfig,
+
+ #[serde(default)]
+ pub(crate) preview: Preview,
+
+ #[serde(default)]
+ pub(crate) daemon: Daemon,
+
+ #[serde(default)]
+ pub(crate) search: Search,
+
+ #[serde(default)]
+ pub(crate) ui: Ui,
+
+ #[serde(default)]
+ pub(crate) logs: Logs,
+
+ #[serde(default)]
+ pub(crate) meta: meta::Settings,
+}
+
+impl Settings {
+ // -- Meta store: lazily initialized on first access --
+
+ pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::meta::MetaStore> {
+ META_STORE
+ .get_or_try_init(|| async {
+ let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
+ eyre!("meta store config not set — Settings::new() has not been called")
+ })?;
+ crate::atuin_client::meta::MetaStore::new(db_path, *timeout).await
+ })
+ .await
+ }
+
+ pub(crate) async fn host_id() -> Result<HostId> {
+ Self::meta_store().await?.host_id().await
+ }
+
+ pub(crate) async fn last_sync() -> Result<OffsetDateTime> {
+ Self::meta_store().await?.last_sync().await
+ }
+
+ pub(crate) async fn save_sync_time() -> Result<()> {
+ Self::meta_store().await?.save_sync_time().await
+ }
+
+ pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode {
+ self.filter_mode
+ .filter(|x| self.search.filters.contains(x))
+ .or_else(|| {
+ self.search
+ .filters
+ .iter()
+ .find(|x| match (x, git_root, self.workspaces) {
+ (FilterMode::Workspace, true, true) => true,
+ (FilterMode::Workspace, _, _) => false,
+ (_, _, _) => true,
+ })
+ .copied()
+ })
+ .unwrap_or(FilterMode::Global)
+ }
+
+ pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> {
+ Self::builder_with_data_dir(&utils::data_dir())
+ }
+
+ #[expect(clippy::too_many_lines)]
+ fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {
+ let db_path = data_dir.join("history.db");
+ let record_store_path = data_dir.join("records.db");
+ let kv_path = data_dir.join("kv.db");
+ let scripts_path = data_dir.join("scripts.db");
+ let ai_sessions_path = data_dir.join("ai_sessions.db");
+ let socket_path = utils::runtime_dir().join("atuin.sock");
+ let pidfile_path = data_dir.join("atuin-daemon.pid");
+ let logs_dir = utils::logs_dir();
+
+ let key_path = data_dir.join("key");
+ let meta_path = data_dir.join("meta.db");
+
+ Ok(Config::builder()
+ .set_default("history_format", "{time}\t{command}\t{duration}")?
+ .set_default("db_path", db_path.to_str())?
+ .set_default("record_store_path", record_store_path.to_str())?
+ .set_default("key_path", key_path.to_str())?
+ .set_default("dialect", "us")?
+ .set_default("timezone", "local")?
+ .set_default("auto_sync", true)?
+ .set_default("sync.address", "https://api.atuin.sh")?
+ .set_default("sync_frequency", "5m")?
+ .set_default("search_mode", "fuzzy")?
+ .set_default("filter_mode", None::<String>)?
+ .set_default("style", "compact")?
+ .set_default("inline_height", 40)?
+ .set_default("show_preview", true)?
+ .set_default("preview.strategy", "auto")?
+ .set_default("max_preview_height", 4)?
+ .set_default("show_help", true)?
+ .set_default("show_tabs", true)?
+ .set_default("show_numeric_shortcuts", true)?
+ .set_default("auto_hide_height", 8)?
+ .set_default("invert", false)?
+ .set_default("exit_mode", "return-original")?
+ .set_default("word_jump_mode", "emacs")?
+ .set_default(
+ "word_chars",
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ )?
+ .set_default("scroll_context_lines", 1)?
+ .set_default("shell_up_key_binding", false)?
+ .set_default("workspaces", false)?
+ .set_default("ctrl_n_shortcuts", false)?
+ .set_default("secrets_filter", true)?
+ .set_default("strip_trailing_whitespace", true)?
+ .set_default("network_connect_timeout", 5)?
+ .set_default("network_timeout", 30)?
+ .set_default("local_timeout", 2.0)?
+ // enter_accept defaults to false here, but true in the default config file. The dissonance is
+ // intentional!
+ // Existing users will get the default "False", so we don't mess with any potential
+ // muscle memory.
+ // New users will get the new default, that is more similar to what they are used to.
+ .set_default("enter_accept", false)?
+ .set_default("keys.scroll_exits", true)?
+ .set_default("keys.accept_past_line_end", true)?
+ .set_default("keys.exit_past_line_start", true)?
+ .set_default("keys.accept_past_line_start", false)?
+ .set_default("keys.accept_with_backspace", false)?
+ .set_default("keys.prefix", "a")?
+ .set_default("keymap_mode", "emacs")?
+ .set_default("keymap_mode_shell", "auto")?
+ .set_default("keymap_cursor", HashMap::<String, String>::new())?
+ .set_default("smart_sort", false)?
+ .set_default("command_chaining", false)?
+ .set_default("store_failed", true)?
+ .set_default("daemon.sync_frequency", 300)?
+ .set_default("daemon.socket_path", socket_path.to_str())?
+ .set_default("daemon.pidfile_path", pidfile_path.to_str())?
+ .set_default("daemon.systemd_socket", false)?
+ .set_default("daemon.tcp_port", 8889)?
+ .set_default("logs.enabled", true)?
+ .set_default("logs.dir", logs_dir.to_str())?
+ .set_default("logs.level", "info")?
+ .set_default("logs.search.file", "search.log")?
+ .set_default("logs.daemon.file", "daemon.log")?
+ .set_default("logs.ai.file", "ai.log")?
+ .set_default("kv.db_path", kv_path.to_str())?
+ .set_default("scripts.db_path", scripts_path.to_str())?
+ .set_default("search.recency_score_multiplier", 1.0)?
+ .set_default("search.frequency_score_multiplier", 1.0)?
+ .set_default("search.frecency_score_multiplier", 1.0)?
+ .set_default("meta.db_path", meta_path.to_str())?
+ .set_default("ai.db_path", ai_sessions_path.to_str())?
+ .set_default("ai.session_continue_minutes", 60)?
+ .set_default("ai.send_cwd", false)?
+ .set_default("ai.opening.send_cwd", false)?
+ .set_default("ai.opening.send_last_command", false)?
+ .set_default(
+ "search.filters",
+ vec![
+ "global",
+ "host",
+ "session",
+ "workspace",
+ "directory",
+ "session-preload",
+ ],
+ )?
+ .set_default("theme.name", "default")?
+ .set_default("theme.debug", None::<bool>)?
+ .set_default("tmux.enabled", false)?
+ .set_default("tmux.width", "80%")?
+ .set_default("tmux.height", "60%")?
+ .set_default(
+ "prefers_reduced_motion",
+ std::env::var("NO_MOTION").ok().map_or_else(
+ || config::Value::new(None, config::ValueKind::Boolean(false)),
+ |_| config::Value::new(None, config::ValueKind::Boolean(true)),
+ ),
+ )?
+ .set_default("no_mouse", false)?
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ ))
+ }
+
+ pub(crate) fn get_config_path() -> Result<PathBuf> {
+ let config_dir = utils::config_dir();
+
+ create_dir_all(&config_dir)
+ .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?;
+
+ let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else(
+ |_| {
+ let mut config_file = PathBuf::new();
+ config_file.push(config_dir);
+ config_file
+ },
+ PathBuf::from,
+ );
+
+ config_file.push("config.toml");
+
+ Ok(config_file)
+ }
+
+ /// Build a merged `Config` from defaults, config file, and environment.
+ ///
+ /// This resolves `data_dir`, initializes the data directory on disk,
+ /// and layers defaults → config file → env overrides. Both `new()` and
+ /// `get_config_value()` use this so the resolution logic lives in one place.
+ fn build_config() -> Result<Config> {
+ let config_file = Self::get_config_path()?;
+
+ // extract data_dir first so we can use it as the base for other path defaults
+ let effective_data_dir = if config_file.exists() {
+ #[derive(Deserialize, Default)]
+ struct DataDirOnly {
+ data_dir: Option<String>,
+ }
+
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+
+ let partial_config = Config::builder()
+ .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ )
+ .build()
+ .ok();
+
+ let custom_data_dir = partial_config
+ .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())
+ .and_then(|d| d.data_dir);
+
+ match custom_data_dir {
+ Some(dir) => {
+ let expanded = shellexpand::full(&dir)
+ .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
+ PathBuf::from(expanded.as_ref())
+ }
+ None => utils::data_dir(),
+ }
+ } else {
+ utils::data_dir()
+ };
+
+ DATA_DIR.set(effective_data_dir.clone()).ok();
+
+ create_dir_all(&effective_data_dir)
+ .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?;
+
+ let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
+
+ config_builder = if config_file.exists() {
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+ config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ } else {
+ // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a
+ // file with defaults. <2026-06-13>
+ create_dir_all(config_file.parent().unwrap())?;
+
+ info!(
+ "No config file at: `{}`. Not adding one.",
+ config_file.display()
+ );
+
+ config_builder
+ };
+
+ // all paths should be expanded
+ let built = config_builder.build_cloned()?;
+ config_builder = [
+ "db_path",
+ "record_store_path",
+ "key_path",
+ "daemon.socket_path",
+ "daemon.pidfile_path",
+ "logs.dir",
+ "logs.search.file",
+ "logs.daemon.file",
+ ]
+ .iter()
+ .map(|key| (key, built.get_string(key).unwrap_or_default()))
+ .filter_map(|(key, value)| match Self::expand_path(&value) {
+ Ok(expanded) => Some((key, expanded)),
+ Err(e) => {
+ log::warn!("failed to expand path for {key}: {e}");
+ None
+ }
+ })
+ .fold(config_builder, |builder, (key, value)| {
+ builder
+ .set_override(key, value)
+ .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
+ });
+
+ config_builder.build().map_err(Into::into)
+ }
+
+ /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
+ ///
+ /// Returns the effective value after merging defaults, config file, and
+ /// environment — without the side-effects of full `Settings` construction
+ /// (meta store init, path expansion, etc.).
+ pub(crate) fn get_config_value(key: &str) -> Result<String> {
+ let config = Self::build_config()?;
+ let value: config::Value = config
+ .get(key)
+ .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
+ Ok(Self::format_resolved_value(&value, key))
+ }
+
+ fn format_resolved_value(value: &config::Value, prefix: &str) -> String {
+ use config::ValueKind;
+
+ match &value.kind {
+ ValueKind::Nil => String::new(),
+ ValueKind::Boolean(b) => b.to_string(),
+ ValueKind::I64(i) => i.to_string(),
+ ValueKind::I128(i) => i.to_string(),
+ ValueKind::U64(u) => u.to_string(),
+ ValueKind::U128(u) => u.to_string(),
+ ValueKind::Float(f) => f.to_string(),
+ ValueKind::String(s) => s.clone(),
+ ValueKind::Array(arr) => {
+ let items: Vec<String> = arr
+ .iter()
+ .map(|v| Self::format_resolved_value(v, ""))
+ .collect();
+ format!("[{}]", items.join(", "))
+ }
+ ValueKind::Table(map) => {
+ let mut lines = Vec::new();
+ let mut keys: Vec<_> = map.keys().collect();
+ keys.sort();
+
+ for k in keys {
+ let v = &map[k];
+ let full_key = if prefix.is_empty() {
+ k.clone()
+ } else {
+ format!("{prefix}.{k}")
+ };
+
+ match &v.kind {
+ ValueKind::Table(_) => {
+ lines.push(Self::format_resolved_value(v, &full_key));
+ }
+ _ => {
+ lines.push(format!(
+ "{} = {}",
+ full_key,
+ Self::format_resolved_value(v, "")
+ ));
+ }
+ }
+ }
+
+ lines.join("\n")
+ }
+ }
+ }
+
+ pub(crate) fn new() -> Result<Self> {
+ let config = Self::build_config()?;
+ let settings: Self = config
+ .try_deserialize()
+ .map_err(|e| eyre!("failed to deserialize: {}", e))?;
+
+ // Validate UI settings
+ settings.ui.validate()?;
+
+ // Register meta store config for lazy initialization on first access
+ META_CONFIG
+ .set((settings.meta.db_path.clone(), settings.local_timeout))
+ .ok();
+
+ Ok(settings)
+ }
+
+ fn expand_path(path: &str) -> Result<String> {
+ shellexpand::full(&path)
+ .map(|p| p.to_string())
+ .map_err(|e| eyre!("failed to expand path: {}", e))
+ }
+
+ pub(crate) fn paths_ok(&self) -> bool {
+ // TODO(@bpeetz): Add the `sync.*` paths <2026-06-11>
+ let paths = [&self.db_path, &self.record_store_path, &self.meta.db_path];
+ paths.iter().all(|p| !utils::broken_symlink(p))
+ }
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ // if this panics something is very wrong, as the default config
+ // does not build or deserialize into the settings struct
+ Self::builder()
+ .expect("Could not build default")
+ .build()
+ .expect("Could not build config")
+ .try_deserialize()
+ .expect("Could not deserialize config")
+ }
+}
+
+#[cfg(test)]
+pub(crate) fn test_local_timeout() -> f64 {
+ std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
+ .ok()
+ .and_then(|x| x.parse().ok())
+ // this hardcoded value should be replaced by a simple way to get the
+ // default local_timeout of Settings if possible
+ .unwrap_or(2.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use eyre::Result;
+
+ use super::Timezone;
+
+ #[test]
+ fn can_parse_offset_timezone_spec() -> Result<()> {
+ assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
+
+ // single digit hours are allowed
+ assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
+
+ // fully qualified form
+ assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
+
+ // these offsets don't really exist but are supported anyway
+ assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
+ assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
+ assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
+ assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
+
+ // require a leading sign for clarity
+ assert!(Timezone::from_str("5").is_err());
+ assert!(Timezone::from_str("10:30").is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn can_choose_workspace_filters_when_in_git_context() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = true;
+
+ assert_eq!(
+ settings.default_filter_mode(true),
+ super::FilterMode::Workspace,
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = true;
+
+ assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,);
+
+ Ok(())
+ }
+
+ #[test]
+ fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> {
+ let mut settings = super::Settings::default();
+ settings.search.filters = vec![
+ super::FilterMode::Workspace,
+ super::FilterMode::Host,
+ super::FilterMode::Directory,
+ super::FilterMode::Session,
+ super::FilterMode::Global,
+ ];
+ settings.workspaces = false;
+
+ assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,);
+
+ Ok(())
+ }
+
+ #[test]
+ fn builder_with_data_dir_uses_custom_paths() -> Result<()> {
+ use std::path::PathBuf;
+
+ let custom_dir = PathBuf::from("/custom/data/dir");
+ let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
+ let config = builder.build()?;
+
+ let db_path: String = config.get("db_path")?;
+ let key_path: String = config.get("key_path")?;
+ let record_store_path: String = config.get("record_store_path")?;
+ let kv_db_path: String = config.get("kv.db_path")?;
+ let scripts_db_path: String = config.get("scripts.db_path")?;
+ let meta_db_path: String = config.get("meta.db_path")?;
+ let daemon_socket_path: String = config.get("daemon.socket_path")?;
+ let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
+
+ assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
+ assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
+ assert_eq!(
+ record_store_path,
+ custom_dir.join("records.db").to_str().unwrap()
+ );
+ assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
+ assert_eq!(
+ scripts_db_path,
+ custom_dir.join("scripts.db").to_str().unwrap()
+ );
+ assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
+ assert_eq!(
+ daemon_socket_path,
+ crate::atuin_common::utils::runtime_dir()
+ .join("atuin.sock")
+ .to_str()
+ .unwrap()
+ );
+ assert_eq!(
+ daemon_pidfile_path,
+ custom_dir.join("atuin-daemon.pid").to_str().unwrap()
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn keymap_config_deserializes_simple_binding() {
+ let json = r#"{"emacs": {"ctrl-c": "exit"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.emacs.len(), 1);
+ match &config.emacs["ctrl-c"] {
+ super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"),
+ _ => panic!("expected Simple variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_conditional_binding() {
+ let json = r#"{
+ "emacs": {
+ "left": [
+ {"when": "cursor-at-start", "action": "exit"},
+ {"action": "cursor-left"}
+ ]
+ }
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ match &config.emacs["left"] {
+ super::KeyBindingConfig::Rules(rules) => {
+ assert_eq!(rules.len(), 2);
+ assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start"));
+ assert_eq!(rules[0].action, "exit");
+ assert!(rules[1].when.is_none());
+ assert_eq!(rules[1].action, "cursor-left");
+ }
+ _ => panic!("expected Rules variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_vim_normal() {
+ let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.vim_normal.len(), 2);
+ assert!(config.emacs.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_is_empty_when_default() {
+ let config = super::KeymapConfig::default();
+ assert!(config.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_mixed_modes() {
+ let json = r#"{
+ "emacs": {"ctrl-c": "exit"},
+ "vim-normal": {"q": "exit"},
+ "inspector": {"d": "delete"}
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert!(!config.is_empty());
+ assert_eq!(config.emacs.len(), 1);
+ assert_eq!(config.vim_normal.len(), 1);
+ assert_eq!(config.inspector.len(), 1);
+ assert!(config.vim_insert.is_empty());
+ assert!(config.prefix.is_empty());
+ }
+}
diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs
new file mode 100644
index 00000000..cc5afcf7
--- /dev/null
+++ b/crates/client/src/atuin_client/settings/meta.rs
@@ -0,0 +1,17 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub(crate) struct Settings {
+ pub(crate) db_path: String,
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ let dir = crate::atuin_common::utils::data_dir();
+ let path = dir.join("meta.db");
+
+ Self {
+ db_path: path.to_string_lossy().to_string(),
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs
new file mode 100644
index 00000000..5eec3692
--- /dev/null
+++ b/crates/client/src/atuin_client/settings/watcher.rs
@@ -0,0 +1,260 @@
+//! Config file watching for automatic settings reload.
+//!
+//! This module provides a `SettingsWatcher` that monitors the config file
+//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.
+//!
+//! # Example
+//!
+//! ```no_run
+//! use crate::atuin_client::settings::watcher::global_settings_watcher;
+//!
+//! async fn example() -> eyre::Result<()> {
+//! let watcher = global_settings_watcher()?;
+//! let mut rx = watcher.subscribe();
+//!
+//! // React to settings changes
+//! while rx.changed().await.is_ok() {
+//! let settings = rx.borrow();
+//! println!("Settings updated!");
+//! }
+//! Ok(())
+//! }
+//! ```
+
+use std::{
+ path::{Path, PathBuf},
+ sync::{Arc, OnceLock},
+ time::Duration,
+};
+
+use eyre::{Result, WrapErr};
+use log::{debug, error, info, warn};
+use notify::{
+ Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
+ event::{EventKind, ModifyKind},
+};
+use tokio::sync::watch;
+
+use super::Settings;
+
+/// Global singleton for the settings watcher.
+static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();
+
+/// Get the global settings watcher singleton.
+///
+/// Initializes the watcher on first call. Subsequent calls return the same instance.
+/// The watcher monitors the config file for changes and broadcasts updates.
+pub(crate) fn global_settings_watcher() -> Result<&'static SettingsWatcher> {
+ let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));
+
+ match result {
+ Ok(watcher) => Ok(watcher),
+ Err(e) => Err(eyre::eyre!("{}", e)),
+ }
+}
+
+/// Watches the config file for changes and broadcasts updated settings.
+///
+/// Uses `notify` for cross-platform file watching and `tokio::sync::watch`
+/// for efficient broadcast to multiple subscribers.
+pub(crate) struct SettingsWatcher {
+ /// Receiver for settings updates. Clone this to subscribe.
+ rx: watch::Receiver<Arc<Settings>>,
+ /// Keeps the file watcher alive for the lifetime of this struct.
+ _watcher: RecommendedWatcher,
+}
+
+impl SettingsWatcher {
+ /// Create a new settings watcher.
+ ///
+ /// Loads initial settings and starts watching the config file for changes.
+ /// Changes are debounced (500ms) to avoid multiple reloads during saves.
+ pub(crate) fn new() -> Result<Self> {
+ let initial_settings = Arc::new(Settings::new()?);
+ let (tx, rx) = watch::channel(initial_settings);
+
+ let config_path = Self::config_path();
+ info!("starting config file watcher: {}", config_path.display());
+
+ let watcher = Self::create_watcher(tx, &config_path)?;
+
+ Ok(Self {
+ rx,
+ _watcher: watcher,
+ })
+ }
+
+ /// Subscribe to settings updates.
+ ///
+ /// Returns a receiver that will be notified when settings change.
+ /// Use `changed().await` to wait for the next update, then `borrow()`
+ /// to access the current settings.
+ pub(crate) fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {
+ self.rx.clone()
+ }
+
+ /// Get the config file path.
+ fn config_path() -> PathBuf {
+ let config_dir = std::env::var("ATUIN_CONFIG_DIR")
+ .map_or_else(|_| crate::atuin_common::utils::config_dir(), PathBuf::from);
+ config_dir.join("config.toml")
+ }
+
+ /// Create the file watcher with debouncing.
+ fn create_watcher(
+ tx: watch::Sender<Arc<Settings>>,
+ config_path: &Path,
+ ) -> Result<RecommendedWatcher> {
+ // Channel for debouncing file events
+ let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();
+
+ // Spawn debounce thread
+ let config_path_clone = config_path.to_owned();
+ std::thread::spawn(move || {
+ Self::debounce_loop(&debounce_rx, &tx, &config_path_clone);
+ });
+
+ // Clone config_path for use in the watcher callback
+ let config_path_for_watcher = config_path.to_owned();
+
+ // Canonicalize config path for reliable comparison on macOS
+ // (handles symlinks like /var -> /private/var)
+ let canonical_config_path = config_path_for_watcher
+ .canonicalize()
+ .unwrap_or_else(|_| config_path_for_watcher.clone());
+
+ // Create file watcher
+ let mut watcher = RecommendedWatcher::new(
+ move |res: Result<notify::Event, notify::Error>| {
+ match res {
+ Ok(event) => {
+ // Defensive: if paths is empty, we can't filter, so assume
+ // it might be our config file and trigger a reload to be safe
+ if event.paths.is_empty() {
+ warn!(
+ "config watcher: event has no paths, triggering reload to be safe"
+ );
+ debounce_tx.send(()).expect("should still be active");
+ return;
+ }
+
+ // Only react to events for our specific config file
+ // (filter out editor temp files, backups, etc.)
+ let is_config_file = event.paths.iter().any(|path| {
+ // Canonicalize for reliable comparison (handles macOS symlinks)
+ let canonical_event_path =
+ path.canonicalize().unwrap_or_else(|_| path.clone());
+
+ // Check if this event is for our config file
+ // (either exact match or the file was renamed to our config)
+ canonical_event_path == canonical_config_path
+ || path.file_name() == config_path_for_watcher.file_name()
+ });
+
+ if !is_config_file {
+ return;
+ }
+
+ // Only react to modify events (content changes) or creates
+ if matches!(
+ event.kind,
+ EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)
+ | EventKind::Create(_)
+ ) {
+ debug!("config file event detected: {event:?}");
+ // Send to debounce channel (ignore send errors - receiver might be gone)
+ debounce_tx.send(()).ok();
+ }
+ }
+ Err(e) => {
+ error!("file watcher error: {e}");
+ }
+ }
+ },
+ NotifyConfig::default(),
+ )
+ .wrap_err("failed to create file watcher")?;
+
+ // Watch the config file's parent directory (some editors create new files)
+ let watch_path = config_path.parent().unwrap_or(config_path);
+
+ // Defensive: ensure watch path exists before trying to watch
+ if !watch_path.exists() {
+ warn!(
+ "config directory does not exist, creating it: {}",
+ watch_path.display()
+ );
+ std::fs::create_dir_all(watch_path).wrap_err_with(|| {
+ format!(
+ "failed to create config directory: {}",
+ watch_path.display()
+ )
+ })?;
+ }
+
+ watcher
+ .watch(watch_path, RecursiveMode::NonRecursive)
+ .wrap_err_with(|| {
+ format!("failed to watch config directory: {}", watch_path.display())
+ })?;
+
+ info!(
+ "config file watcher initialized for: {}",
+ watch_path.display()
+ );
+ Ok(watcher)
+ }
+
+ /// Debounce loop that batches file events and reloads settings.
+ fn debounce_loop(
+ rx: &std::sync::mpsc::Receiver<()>,
+ tx: &watch::Sender<Arc<Settings>>,
+ config_path: &Path,
+ ) {
+ const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
+
+ loop {
+ // Wait for first event
+ if rx.recv().is_err() {
+ // Channel closed, watcher was dropped
+ debug!("config watcher debounce loop exiting");
+ return;
+ }
+
+ // Drain any additional events within debounce window
+ while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {
+ // Keep draining
+ }
+
+ // Defensive: check if config file exists before reloading
+ // (handles case where file was deleted - we'll get notified when it's recreated)
+ if !config_path.exists() {
+ debug!(
+ "config file does not exist, skipping reload: {}",
+ config_path.display()
+ );
+ continue;
+ }
+
+ // Now reload settings
+ info!(
+ "config file changed, reloading settings: {}",
+ config_path.display()
+ );
+ match Settings::new() {
+ Ok(settings) => {
+ if tx.send(Arc::new(settings)).is_err() {
+ // All receivers dropped
+ debug!("all settings subscribers dropped, exiting");
+ return;
+ }
+ info!("settings reloaded successfully");
+ }
+ Err(e) => {
+ warn!("failed to reload settings: {e}");
+ // Keep the old settings, don't broadcast the error
+ }
+ }
+ }
+ }
+}
diff --git a/crates/client/src/atuin_client/theme.rs b/crates/client/src/atuin_client/theme.rs
new file mode 100644
index 00000000..ec0538e9
--- /dev/null
+++ b/crates/client/src/atuin_client/theme.rs
@@ -0,0 +1,41 @@
+use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
+pub(crate) fn style_base() -> ContentStyle {
+ ContentStyle::default()
+}
+pub(crate) fn style_annotation() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::DarkGrey),
+ ..ContentStyle::default()
+ }
+}
+pub(crate) fn style_important() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::White),
+ attributes: Attributes::from(Attribute::Bold),
+ ..ContentStyle::default()
+ }
+}
+pub(crate) fn style_guidance() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::DarkBlue),
+ ..ContentStyle::default()
+ }
+}
+pub(crate) fn style_alerterror() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::DarkRed),
+ ..ContentStyle::default()
+ }
+}
+pub(crate) fn style_alertinfo() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::DarkGreen),
+ ..ContentStyle::default()
+ }
+}
+pub(crate) fn style_alertwarn() -> ContentStyle {
+ ContentStyle {
+ foreground_color: Some(Color::DarkYellow),
+ ..ContentStyle::default()
+ }
+}
diff --git a/crates/client/src/atuin_client/utils.rs b/crates/client/src/atuin_client/utils.rs
new file mode 100644
index 00000000..989f9fc1
--- /dev/null
+++ b/crates/client/src/atuin_client/utils.rs
@@ -0,0 +1,92 @@
+pub(crate) fn get_hostname() -> String {
+ std::env::var("ATUIN_HOST_NAME")
+ .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string()))
+}
+
+pub(crate) fn get_username() -> String {
+ std::env::var("ATUIN_HOST_USER")
+ .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string()))
+}
+
+/// Returns a pair of the hostname and username, separated by a colon.
+pub(crate) fn get_host_user() -> String {
+ format!("{}:{}", get_hostname(), get_username())
+}
+
+/// Setup a [`SQLite`] database.
+///
+/// This takes care of correct locking, so that we avoid a race when setting up the database.
+macro_rules! setup_db {
+ (
+ $db_path:expr,
+ $a_timeout:expr,
+ $opts:expr,
+ $m_name:literal $(,)?
+ ) => {{
+ async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> {
+ { sqlx::sqlx_macros::migrate!($m_name) }.run(pool).await?;
+ Ok(())
+ }
+
+ crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
+ }};
+}
+pub(crate) use setup_db;
+
+use std::{os::fd::AsRawFd, path::Path, time::Duration};
+
+use fs_err::OpenOptions;
+use sqlx::{
+ SqlitePool,
+ sqlite::{SqliteConnectOptions, SqlitePoolOptions},
+};
+use tracing::debug;
+
+/// Helper for `setup_db!`
+pub(crate) async fn setup_db_inner(
+ db_path: &Path,
+ acquire_timeout: f64,
+ mk_opts: fn(&str) -> sqlx::Result<SqliteConnectOptions>,
+ migrate: impl AsyncFn(&SqlitePool) -> sqlx::Result<()>,
+) -> sqlx::Result<SqlitePool> {
+ async fn open_db(timeout: f64, opts: SqliteConnectOptions) -> sqlx::Result<SqlitePool> {
+ let pool = SqlitePoolOptions::new()
+ .acquire_timeout(Duration::from_secs_f64(timeout))
+ .connect_with(opts)
+ .await?;
+
+ Ok(pool)
+ }
+
+ {
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .open(db_path)?;
+
+ // Lock the db file while we are running the migrations.
+ // Why? Because there is a small chance that we start running migrations (e.g. as the daemon)
+ // and then another process is started, which will also try to run migrations.
+ // Essentially, one of the processes will receive with a SQLite UNIQUE constraint failure.
+ // So let's avoid that possibility from the start.
+ file.lock()?;
+
+ let pool = open_db(
+ acquire_timeout,
+ mk_opts(format!("/proc/self/fd/{}", file.as_raw_fd()).as_str())?,
+ )
+ .await?;
+
+ debug!("running sqlite database setup");
+
+ migrate(&pool).await?;
+
+ file.unlock()?;
+ }
+
+ let real_opts = mk_opts(db_path.to_str().expect("Should be utf-8"))?;
+ let pool = open_db(acquire_timeout, real_opts).await?;
+
+ Ok(pool)
+}
diff --git a/crates/client/src/atuin_common/api.rs b/crates/client/src/atuin_common/api.rs
new file mode 100644
index 00000000..0868943d
--- /dev/null
+++ b/crates/client/src/atuin_common/api.rs
@@ -0,0 +1,22 @@
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use std::borrow::Cow;
+use std::sync::LazyLock;
+
+// the usage of X- has been deprecated for quite along time, it turns out
+pub(crate) static ATUIN_HEADER_VERSION: &str = "Atuin-Version";
+pub(crate) static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+pub(crate) static ATUIN_VERSION: LazyLock<Version> =
+ LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver"));
+
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct ErrorResponse<'a> {
+ pub(crate) reason: Cow<'a, str>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct IndexResponse {
+ pub(crate) homage: String,
+ pub(crate) version: String,
+}
diff --git a/crates/client/src/atuin_common/mod.rs b/crates/client/src/atuin_common/mod.rs
new file mode 100644
index 00000000..635c9fc3
--- /dev/null
+++ b/crates/client/src/atuin_common/mod.rs
@@ -0,0 +1,58 @@
+/// Defines a new UUID type wrapper
+macro_rules! new_uuid {
+ ($name:ident) => {
+ #[derive(
+ Debug,
+ Copy,
+ Clone,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+ )]
+ #[serde(transparent)]
+ pub(crate) struct $name(pub(crate) Uuid);
+
+ impl<DB: sqlx::Database> sqlx::Type<DB> for $name
+ where
+ Uuid: sqlx::Type<DB>,
+ {
+ fn type_info() -> <DB as sqlx::Database>::TypeInfo {
+ Uuid::type_info()
+ }
+ }
+
+ impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name
+ where
+ Uuid: sqlx::Decode<'r, DB>,
+ {
+ fn decode(
+ value: DB::ValueRef<'r>,
+ ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
+ Uuid::decode(value).map(Self)
+ }
+ }
+
+ impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name
+ where
+ Uuid: sqlx::Encode<'q, DB>,
+ {
+ fn encode_by_ref(
+ &self,
+ buf: &mut DB::ArgumentBuffer<'q>,
+ ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync + 'static>>
+ {
+ self.0.encode_by_ref(buf)
+ }
+ }
+ };
+}
+
+pub(crate) mod api;
+pub(crate) mod record;
+pub(crate) mod shell;
+pub(crate) mod tls;
+pub(crate) mod utils;
diff --git a/crates/client/src/atuin_common/record.rs b/crates/client/src/atuin_common/record.rs
new file mode 100644
index 00000000..f8f9f8a7
--- /dev/null
+++ b/crates/client/src/atuin_common/record.rs
@@ -0,0 +1,284 @@
+use std::collections::HashMap;
+
+use eyre::Result;
+use serde::{Deserialize, Serialize};
+use typed_builder::TypedBuilder;
+use uuid::Uuid;
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct DecryptedData(pub(crate) Vec<u8>);
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub(crate) struct EncryptedData {
+ pub(crate) data: String,
+ pub(crate) content_encryption_key: String,
+}
+
+#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
+pub(crate) struct Diff {
+ pub(crate) host: HostId,
+ pub(crate) tag: String,
+ pub(crate) local: Option<RecordIdx>,
+ pub(crate) remote: Option<RecordIdx>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub(crate) struct Host {
+ pub(crate) id: HostId,
+ pub(crate) name: String,
+}
+
+impl Host {
+ pub(crate) fn new(id: HostId) -> Self {
+ Self {
+ id,
+ name: String::new(),
+ }
+ }
+}
+
+new_uuid!(RecordId);
+new_uuid!(HostId);
+
+pub(crate) type RecordIdx = u64;
+
+/// A single record stored inside of our local database
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
+pub(crate) struct Record<Data> {
+ /// a unique ID
+ #[builder(default = RecordId(crate::atuin_common::utils::uuid_v7()))]
+ pub(crate) id: RecordId,
+
+ /// The integer record ID. This is only unique per (host, tag).
+ pub(crate) idx: RecordIdx,
+
+ /// The unique ID of the host.
+ // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store
+ // as strings. I would rather avoid normalization, so store as UUID binary instead of
+ // encoding to a string and wasting much more storage.
+ pub(crate) host: Host,
+
+ /// The creation time in nanoseconds since unix epoch
+ #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)]
+ pub(crate) timestamp: u64,
+
+ /// The version the data in the entry conforms to
+ // However we want to track versions for this tag, eg v2
+ pub(crate) version: String,
+
+ /// The type of data we are storing here. Eg, "history"
+ pub(crate) tag: String,
+
+ /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it.
+ pub(crate) data: Data,
+}
+
+/// Extra data from the record that should be encoded in the data
+#[derive(Debug, Copy, Clone)]
+pub(crate) struct AdditionalData<'a> {
+ pub(crate) id: &'a RecordId,
+ pub(crate) idx: &'a u64,
+ pub(crate) version: &'a str,
+ pub(crate) tag: &'a str,
+ pub(crate) host: &'a HostId,
+}
+
+/// An index representing the current state of the record stores
+/// This can be both remote, or local, and compared in either direction
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct RecordStatus {
+ // A map of host -> tag -> max(idx)
+ pub(crate) hosts: HashMap<HostId, HashMap<String, RecordIdx>>,
+}
+
+impl Default for RecordStatus {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Extend<(HostId, String, RecordIdx)> for RecordStatus {
+ fn extend<T: IntoIterator<Item = (HostId, String, RecordIdx)>>(&mut self, iter: T) {
+ for (host, tag, tail_idx) in iter {
+ self.set_raw(host, tag, tail_idx);
+ }
+ }
+}
+
+impl RecordStatus {
+ pub(crate) fn new() -> Self {
+ Self {
+ hosts: HashMap::new(),
+ }
+ }
+
+ /// Insert a new tail record into the store
+ pub(crate) fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) {
+ self.hosts.entry(host).or_default().insert(tag, tail_id);
+ }
+
+ pub(crate) fn get(&self, host: HostId, tag: &str) -> Option<RecordIdx> {
+ self.hosts.get(&host).and_then(|v| v.get(tag)).copied()
+ }
+
+ /// Diff this index with another, likely remote index.
+ /// The two diffs can then be reconciled, and the optimal change set calculated
+ /// Returns a tuple, with (host, tag, Option(OTHER))
+ /// OTHER is set to the value of the idx on the other machine. If it is greater than our index,
+ /// then we need to do some downloading. If it is smaller, then we need to do some uploading
+ /// Note that we cannot upload if we are not the owner of the record store - hosts can only
+ /// write to their own store.
+ pub(crate) fn diff(&self, other: &Self) -> Vec<Diff> {
+ let mut ret = Vec::new();
+
+ // First, we check if other has everything that self has
+ for (host, tag_map) in &self.hosts {
+ for (tag, idx) in tag_map {
+ match other.get(*host, tag) {
+ // The other store is all up to date! No diff.
+ Some(t) if t.eq(idx) => (),
+
+ // The other store does exist, and it is either ahead or behind us. A diff regardless
+ Some(t) => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: Some(t),
+ }),
+
+ // The other store does not exist :O
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ local: Some(*idx),
+ remote: None,
+ }),
+ }
+ }
+ }
+
+ // At this point, there is a single case we have not yet considered.
+ // If the other store knows of a tag that we are not yet aware of, then the diff will be missed
+
+ // account for that!
+ for (host, tag_map) in &other.hosts {
+ for (tag, idx) in tag_map {
+ match self.get(*host, tag) {
+ // If we have this host/tag combo, the comparison and diff will have already happened above
+ Some(_) => (),
+
+ None => ret.push(Diff {
+ host: *host,
+ tag: tag.clone(),
+ remote: Some(*idx),
+ local: None,
+ }),
+ }
+ }
+ }
+
+ // Stability is a nice property to have
+ ret.sort();
+ ret
+ }
+}
+
+pub(crate) trait Encryption {
+ fn re_encrypt(
+ data: EncryptedData,
+ ad: AdditionalData<'_>,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<EncryptedData> {
+ let data = Self::decrypt(data, ad, old_key)?;
+ Ok(Self::encrypt(data, ad, new_key))
+ }
+ fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData;
+ fn decrypt(
+ data: EncryptedData,
+ ad: AdditionalData<'_>,
+ key: &[u8; 32],
+ ) -> Result<DecryptedData>;
+}
+
+impl Record<DecryptedData> {
+ pub(crate) fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Record {
+ data: E::encrypt(self.data, ad, key),
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ }
+ }
+}
+
+impl Record<EncryptedData> {
+ pub(crate) fn decrypt<E: Encryption>(self, key: &[u8; 32]) -> Result<Record<DecryptedData>> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Record {
+ data: E::decrypt(self.data, ad, key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+
+ pub(crate) fn re_encrypt<E: Encryption>(
+ self,
+ old_key: &[u8; 32],
+ new_key: &[u8; 32],
+ ) -> Result<Self> {
+ let ad = AdditionalData {
+ id: &self.id,
+ version: &self.version,
+ tag: &self.tag,
+ host: &self.host.id,
+ idx: &self.idx,
+ };
+ Ok(Self {
+ data: E::re_encrypt(self.data, ad, old_key, new_key)?,
+ id: self.id,
+ host: self.host,
+ idx: self.idx,
+ timestamp: self.timestamp,
+ version: self.version,
+ tag: self.tag,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_common::record::{Host, HostId};
+
+ use super::{DecryptedData, Record};
+
+ fn test_record() -> Record<DecryptedData> {
+ Record::builder()
+ .host(Host::new(HostId(crate::atuin_common::utils::uuid_v7())))
+ .version("v1".into())
+ .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
+ .data(DecryptedData(vec![0, 1, 2, 3]))
+ .idx(0)
+ .build()
+ }
+}
diff --git a/crates/client/src/atuin_common/shell.rs b/crates/client/src/atuin_common/shell.rs
new file mode 100644
index 00000000..880ff00f
--- /dev/null
+++ b/crates/client/src/atuin_common/shell.rs
@@ -0,0 +1,52 @@
+#[derive(PartialEq)]
+pub(crate) enum Shell {
+ Sh,
+ Bash,
+ Fish,
+ Zsh,
+ Xonsh,
+ Nu,
+ Powershell,
+
+ Unknown,
+}
+
+impl std::fmt::Display for Shell {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let shell = match self {
+ Self::Bash => "bash",
+ Self::Fish => "fish",
+ Self::Zsh => "zsh",
+ Self::Nu => "nu",
+ Self::Xonsh => "xonsh",
+ Self::Sh => "sh",
+ Self::Powershell => "powershell",
+
+ Self::Unknown => "unknown",
+ };
+
+ write!(f, "{shell}")
+ }
+}
+
+impl Shell {
+ pub(crate) fn from_env() -> Self {
+ std::env::var("ATUIN_SHELL").map_or(Self::Unknown, |shell| {
+ Self::from_string(shell.trim().to_lowercase().as_str())
+ })
+ }
+
+ pub(crate) fn from_string(name: &str) -> Self {
+ match name {
+ "bash" => Self::Bash,
+ "fish" => Self::Fish,
+ "zsh" => Self::Zsh,
+ "xonsh" => Self::Xonsh,
+ "nu" => Self::Nu,
+ "sh" => Self::Sh,
+ "powershell" => Self::Powershell,
+
+ _ => Self::Unknown,
+ }
+ }
+}
diff --git a/crates/client/src/atuin_common/tls.rs b/crates/client/src/atuin_common/tls.rs
new file mode 100644
index 00000000..3736f878
--- /dev/null
+++ b/crates/client/src/atuin_common/tls.rs
@@ -0,0 +1,15 @@
+use std::sync::Once;
+
+static INIT: Once = Once::new();
+
+/// Ensure the rustls crypto provider (ring) is installed.
+///
+/// Must be called before creating any reqwest clients. Safe to call
+/// multiple times — only the first call installs the provider.
+pub(crate) fn ensure_crypto_provider() {
+ INIT.call_once(|| {
+ rustls::crypto::ring::default_provider()
+ .install_default()
+ .expect("Failed to install rustls crypto provider");
+ });
+}
diff --git a/crates/client/src/atuin_common/utils.rs b/crates/client/src/atuin_common/utils.rs
new file mode 100644
index 00000000..dbe9dfbc
--- /dev/null
+++ b/crates/client/src/atuin_common/utils.rs
@@ -0,0 +1,257 @@
+use std::borrow::Cow;
+use std::env;
+use std::path::{Path, PathBuf};
+
+use uuid::Uuid;
+
+pub(crate) fn uuid_v7() -> Uuid {
+ Uuid::now_v7()
+}
+
+pub(crate) fn has_git_dir(path: &str) -> bool {
+ let mut gitdir = PathBuf::from(path);
+ gitdir.push(".git");
+
+ gitdir.exists()
+}
+
+// in a git worktree, .git is a file containing "gitdir: <path>" pointing
+// to the main repo's .git/worktrees/<name> directory. follow the pointer
+// back to the main repo root so all worktrees share a workspace.
+fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
+ let git_path = path.join(".git");
+
+ if !git_path.is_file() {
+ return None;
+ }
+
+ let contents = std::fs::read_to_string(&git_path).ok()?;
+ let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
+
+ let gitdir = PathBuf::from(gitdir_str);
+ let gitdir = if gitdir.is_absolute() {
+ gitdir
+ } else {
+ path.join(gitdir_str)
+ };
+
+ // walk up from e.g. /repo/.git/worktrees/feature to find /repo
+ let mut candidate = gitdir.as_path();
+ while let Some(parent) = candidate.parent() {
+ if parent.join(".git").is_dir() {
+ return Some(parent.to_path_buf());
+ }
+ candidate = parent;
+ }
+
+ None
+}
+
+// detect if any parent dir has a git repo in it
+// I really don't want to bring in libgit for something simple like this
+// If we start to do anything more advanced, then perhaps
+pub(crate) fn in_git_repo(path: &str) -> Option<PathBuf> {
+ let mut gitdir = PathBuf::from(path);
+
+ while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
+ gitdir.pop();
+ }
+
+ // No parent? then we hit root, finding no git
+ if gitdir.parent().is_some() {
+ // if .git is a file (worktree), resolve to the main repo root
+ if let Some(main_repo) = resolve_git_worktree(&gitdir) {
+ return Some(main_repo);
+ }
+ return Some(gitdir);
+ }
+
+ None
+}
+
+// TODO: more reliable, more tested
+// I don't want to use ProjectDirs, it puts config in awkward places on
+// mac. Data too. Seems to be more intended for GUI apps.
+
+pub(crate) fn home_dir() -> PathBuf {
+ directories::BaseDirs::new()
+ .map(|d| d.home_dir().to_path_buf())
+ .expect("could not determine home directory")
+}
+
+pub(crate) fn config_dir() -> PathBuf {
+ let config_dir =
+ env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
+ config_dir.join("atuin")
+}
+
+pub(crate) fn data_dir() -> PathBuf {
+ let data_dir = env::var("XDG_DATA_HOME")
+ .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
+
+ data_dir.join("atuin")
+}
+
+pub(crate) fn runtime_dir() -> PathBuf {
+ env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
+}
+
+pub(crate) fn logs_dir() -> PathBuf {
+ home_dir().join(".atuin").join("logs")
+}
+
+pub(crate) fn get_current_dir() -> String {
+ // Prefer PWD environment variable over cwd if available to better support symbolic links
+ env::var("PWD").unwrap_or_else(|_| {
+ env::current_dir().map_or_else(|_| String::new(), |dir| dir.display().to_string())
+ })
+}
+
+pub(crate) fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
+ let path = path.into();
+ path.is_symlink() && !path.exists()
+}
+
+/// Extension trait for anything that can behave like a string to make it easy to escape control
+/// characters.
+///
+/// Intended to help prevent control characters being printed and interpreted by the terminal when
+/// printing history as well as to ensure the commands that appear in the interactive search
+/// reflect the actual command run rather than just the printable characters.
+pub(crate) trait Escapable: AsRef<str> {
+ fn escape_control(&self) -> Cow<'_, str> {
+ if self.as_ref().contains(|c: char| c.is_ascii_control()) {
+ let mut remaining = self.as_ref();
+ // Not a perfect way to reserve space but should reduce the allocations
+ let mut buf = String::with_capacity(remaining.len());
+ while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
+ // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
+ buf.push_str(&remaining[..i]);
+ buf.push('^');
+ buf.push(match remaining.as_bytes()[i] {
+ 0x7F => '?',
+ code => char::from_u32(u32::from(code) + 64).unwrap(),
+ });
+ remaining = &remaining[i + 1..];
+ }
+ buf.push_str(remaining);
+ buf.into()
+ } else {
+ self.as_ref().into()
+ }
+ }
+}
+
+impl<T: AsRef<str>> Escapable for T {}
+
+#[cfg(test)]
+mod tests {
+ use super::{Cow, Escapable, Uuid, env, in_git_repo, uuid_v7};
+
+ use std::collections::HashSet;
+
+ #[test]
+ fn uuid_is_unique() {
+ let how_many: usize = 1_000_000;
+
+ // for peace of mind
+ let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
+
+ // there will be many in the same millisecond
+ for _ in 0..how_many {
+ let uuid = uuid_v7();
+ uuids.insert(uuid);
+ }
+
+ assert_eq!(uuids.len(), how_many);
+ }
+
+ #[test]
+ fn escape_control_characters() {
+ use super::Escapable;
+ // CSI colour sequence
+ assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
+
+ // Tabs count as control chars
+ assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
+
+ // space is in control char range but should be excluded
+ assert_eq!("two words".escape_control(), "two words");
+
+ // unicode multi-byte characters
+ let s = "🐢\x1b[32m🦀";
+ assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
+ }
+
+ #[test]
+ fn escape_no_control_characters() {
+ use super::Escapable as _;
+ assert!(matches!(
+ "no control characters".escape_control(),
+ Cow::Borrowed(_)
+ ));
+ assert!(matches!(
+ "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
+ Cow::Owned(_)
+ ));
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn in_git_repo_regular() {
+ // regular git repo should resolve to the directory containing .git
+ let tmp = env::temp_dir().join("atuin-test-regular-git");
+ drop(std::fs::remove_dir_all(&tmp));
+ let subdir = tmp.join("src").join("deep");
+ std::fs::create_dir_all(&subdir).unwrap();
+ std::fs::create_dir_all(tmp.join(".git")).unwrap();
+
+ let result = in_git_repo(subdir.to_str().unwrap());
+ assert_eq!(result, Some(tmp.clone()));
+
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn in_git_repo_worktree_resolves_to_main_repo() {
+ // worktree .git is a file pointing back to the main repo —
+ // in_git_repo should follow it so all worktrees share a workspace
+ let tmp = env::temp_dir().join("atuin-test-worktree-git");
+ drop(std::fs::remove_dir_all(&tmp));
+
+ // main repo at tmp/main with a real .git directory
+ let main_repo = tmp.join("main");
+ let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
+ std::fs::create_dir_all(&worktree_git_dir).unwrap();
+
+ // worktree at tmp/worktree with a .git file
+ let worktree = tmp.join("worktree");
+ let worktree_subdir = worktree.join("src");
+ std::fs::create_dir_all(&worktree_subdir).unwrap();
+ std::fs::write(
+ worktree.join(".git"),
+ format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
+ )
+ .unwrap();
+
+ // should resolve to the main repo root, not the worktree root
+ let result = in_git_repo(worktree_subdir.to_str().unwrap());
+ assert_eq!(result, Some(main_repo.clone()));
+
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn dumb_random_test() {
+ // Obviously not a test of randomness, but make sure we haven't made some
+ // catastrophic error
+
+ assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
+ assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
+ assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
+ assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
+ assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
+ assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
+ }
+}
diff --git a/crates/client/src/atuin_history/mod.rs b/crates/client/src/atuin_history/mod.rs
new file mode 100644
index 00000000..41336a14
--- /dev/null
+++ b/crates/client/src/atuin_history/mod.rs
@@ -0,0 +1,2 @@
+pub(crate) mod sort;
+pub(crate) mod stats;
diff --git a/crates/client/src/atuin_history/sort.rs b/crates/client/src/atuin_history/sort.rs
new file mode 100644
index 00000000..3143fa68
--- /dev/null
+++ b/crates/client/src/atuin_history/sort.rs
@@ -0,0 +1,46 @@
+use crate::atuin_client::history::History;
+
+type ScoredHistory = (f64, History);
+
+// Fuzzy search already comes sorted by minspan
+// This sorting should be applicable to all search modes, and solve the more "obvious" issues
+// first.
+// Later on, we can pass in context and do some boosts there too.
+pub(crate) fn sort(query: &str, input: Vec<History>) -> Vec<History> {
+ // This can totally be extended. We need to be _careful_ that it's not slow.
+ // We also need to balance sorting db-side with sorting here. SQLite can do a lot,
+ // but some things are just much easier/more doable in Rust.
+
+ let mut scored = input
+ .into_iter()
+ .map(|h| {
+ // If history is _prefixed_ with the query, score it more highly
+ let score = if h.command.starts_with(query) {
+ 2.0
+ } else if h.command.contains(query) {
+ 1.75
+ } else {
+ 1.0
+ };
+
+ // calculate how long ago the history was, in seconds
+ let now = time::OffsetDateTime::now_utc().unix_timestamp();
+ let time = h.timestamp.unix_timestamp();
+ let diff = std::cmp::max(1, now - time); // no /0 please
+
+ // prefer newer history, but not hugely so as to offset the other scoring
+ // the numbers will get super small over time, but I don't want time to overpower other
+ // scoring
+ #[expect(clippy::cast_precision_loss)]
+ let time_score = 1.0 + (1.0 / diff as f64);
+ let score = score * time_score;
+
+ (score, h)
+ })
+ .collect::<Vec<ScoredHistory>>();
+
+ scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().reverse());
+
+ // Remove the scores and return the history
+ scored.into_iter().map(|(_, h)| h).collect::<Vec<History>>()
+}
diff --git a/crates/client/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs
new file mode 100644
index 00000000..c53dafb2
--- /dev/null
+++ b/crates/client/src/atuin_history/stats.rs
@@ -0,0 +1,527 @@
+use std::collections::{HashMap, HashSet};
+
+use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
+use serde::{Deserialize, Serialize};
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::atuin_client::{history::History, settings::Settings};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub(crate) struct Stats {
+ pub(crate) total_commands: usize,
+ pub(crate) unique_commands: usize,
+ pub(crate) top: Vec<(Vec<String>, usize)>,
+}
+
+fn first_non_whitespace(s: &str) -> Option<usize> {
+ s.char_indices()
+ // find the first non whitespace char
+ .find(|(_, c)| !c.is_ascii_whitespace())
+ // return the index of that char
+ .map(|(i, _)| i)
+}
+
+fn first_whitespace(s: &str) -> usize {
+ s.char_indices()
+ // find the first whitespace char
+ .find(|(_, c)| c.is_ascii_whitespace())
+ // return the index of that char, (or the max length of the string)
+ .map_or(s.len(), |(i, _)| i)
+}
+
+fn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {
+ // Sort by length so that we match the longest prefix first
+ let mut common_prefix = settings.stats.common_prefix.clone();
+ common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));
+
+ // Trim off the common prefix, if it exists
+ for p in &common_prefix {
+ if command.starts_with(p) {
+ let i = p.len();
+ let prefix = &command[..i];
+ command = command[i..].trim_start();
+ if command.is_empty() {
+ // no commands following, just use the prefix
+ return prefix;
+ }
+ break;
+ }
+ }
+
+ // Sort the common_subcommands by length so that we match the longest subcommand first
+ let mut common_subcommands = settings.stats.common_subcommands.clone();
+ common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));
+
+ // Check for a common subcommand
+ for p in &common_subcommands {
+ if command.starts_with(p) {
+ // if the subcommand is the same length as the command, then we just use the subcommand
+ if p.len() == command.len() {
+ return command;
+ }
+ // otherwise we need to use the subcommand + the next word
+ let non_whitespace = first_non_whitespace(&command[p.len()..]).unwrap_or(0);
+ let j =
+ p.len() + non_whitespace + first_whitespace(&command[p.len() + non_whitespace..]);
+ return &command[..j];
+ }
+ }
+ // Return the first word if there is no subcommand
+ &command[..first_whitespace(command)]
+}
+
+fn split_at_pipe(command: &str) -> Vec<&str> {
+ let mut result = vec![];
+ let mut quoted = false;
+ let mut start = 0;
+ let mut graphemes = UnicodeSegmentation::grapheme_indices(command, true);
+
+ while let Some((i, c)) = graphemes.next() {
+ let current = i;
+ match c {
+ "\"" if command[start..current] != *"\"" => {
+ quoted = !quoted;
+ }
+ "'" if command[start..current] != *"'" => {
+ quoted = !quoted;
+ }
+ "\\" if graphemes.next().is_some() => {}
+ "|" if !quoted => {
+ if current > start && command[start..].starts_with('|') {
+ start += 1;
+ }
+ result.push(&command[start..current]);
+ start = current;
+ }
+ _ => {}
+ }
+ }
+ if command[start..].starts_with('|') {
+ start += 1;
+ }
+ result.push(&command[start..]);
+ result
+}
+
+fn strip_leading_env_vars(command: &str) -> &str {
+ // fast path: no equals sign, no environment variable
+ if !command.contains('=') {
+ return command;
+ }
+
+ let mut in_token = false;
+ let mut token_start_pos = 0;
+ let mut in_single_quotes = false;
+ let mut in_double_quotes = false;
+ let mut escape_next = false;
+ let mut has_equals_outside_quotes = false;
+
+ for (i, g) in UnicodeSegmentation::grapheme_indices(command, true) {
+ if escape_next {
+ escape_next = false;
+ continue;
+ }
+
+ if !in_token {
+ token_start_pos = i;
+ }
+
+ match g {
+ "\\" => {
+ escape_next = true;
+ in_token = true;
+ }
+ "'" if !in_double_quotes => {
+ in_single_quotes = !in_single_quotes;
+ in_token = true;
+ }
+ "\"" if !in_single_quotes => {
+ in_double_quotes = !in_double_quotes;
+ in_token = true;
+ }
+ "=" if !in_single_quotes && !in_double_quotes => {
+ has_equals_outside_quotes = true;
+ in_token = true;
+ }
+ " " | "\t" if !in_single_quotes && !in_double_quotes => {
+ if in_token {
+ if !has_equals_outside_quotes {
+ // if we're not in an env var, we can break early
+ break;
+ }
+ in_token = false;
+ has_equals_outside_quotes = false;
+ }
+ }
+ _ => {
+ in_token = true;
+ }
+ }
+ }
+
+ command[token_start_pos..].trim()
+}
+
+pub(crate) fn pretty_print(stats: Stats, ngram_size: usize) {
+ let max = stats.top.iter().map(|x| x.1).max().unwrap();
+ let num_pad = max.ilog10() as usize + 1;
+
+ // Find the length of the longest command name for each column
+ let column_widths = stats
+ .top
+ .iter()
+ .map(|(commands, _)| commands.iter().map(String::len).collect::<Vec<usize>>())
+ .fold(vec![0; ngram_size], |acc, item| {
+ acc.iter()
+ .zip(item.iter())
+ .map(|(a, i)| *std::cmp::max(a, i))
+ .collect()
+ });
+
+ for (command, count) in stats.top {
+ let gray = SetForegroundColor(Color::Grey);
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ let in_ten = 10 * count / max;
+
+ print!("[");
+ print!("{}", SetForegroundColor(Color::Red));
+
+ for i in 0..in_ten {
+ if i == 2 {
+ print!("{}", SetForegroundColor(Color::Yellow));
+ }
+
+ if i == 5 {
+ print!("{}", SetForegroundColor(Color::Green));
+ }
+
+ print!("▮");
+ }
+
+ for _ in in_ten..10 {
+ print!(" ");
+ }
+
+ let formatted_command = command
+ .iter()
+ .zip(column_widths.iter())
+ .map(|(cmd, width)| format!("{cmd:width$}"))
+ .collect::<Vec<_>>()
+ .join(" | ");
+
+ println!(
+ "{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{formatted_command}{ResetColor}"
+ );
+ }
+ println!("Total commands: {}", stats.total_commands);
+ println!("Unique commands: {}", stats.unique_commands);
+}
+
+pub(crate) fn compute(
+ settings: &Settings,
+ history: &[History],
+ count: usize,
+ ngram_size: usize,
+) -> Option<Stats> {
+ let mut commands = HashSet::<&str>::with_capacity(history.len());
+ let mut total_unignored = 0;
+ let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());
+
+ for i in history {
+ // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes)
+ let command = strip_leading_env_vars(i.command.trim());
+ let prefix = interesting_command(settings, command);
+
+ if settings.stats.ignored_commands.iter().any(|c| c == prefix) {
+ continue;
+ }
+
+ total_unignored += 1;
+ commands.insert(command);
+
+ split_at_pipe(command)
+ .iter()
+ .map(|l| {
+ let command = l.trim();
+ commands.insert(command);
+ command
+ })
+ .collect::<Vec<_>>()
+ .windows(ngram_size)
+ .for_each(|w| {
+ *prefixes
+ .entry(w.iter().map(|c| interesting_command(settings, c)).collect())
+ .or_default() += 1;
+ });
+ }
+
+ let unique = commands.len();
+ let mut top = prefixes.into_iter().collect::<Vec<_>>();
+
+ top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
+ top.truncate(count);
+
+ if top.is_empty() {
+ return None;
+ }
+
+ Some(Stats {
+ unique_commands: unique,
+ total_commands: total_unignored,
+ top: top
+ .into_iter()
+ .map(|t| (t.0.into_iter().map(ToString::to_string).collect(), t.1))
+ .collect(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_client::history::History;
+ use crate::atuin_client::settings::Settings;
+ use time::OffsetDateTime;
+
+ use super::compute;
+ use super::{interesting_command, split_at_pipe, strip_leading_env_vars};
+
+ #[test]
+ fn ignored_env_vars() {
+ let settings = Settings::new().unwrap();
+
+ let history: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("FOO='BAR=🚀' echo foo")
+ .cwd("/")
+ .build()
+ .into();
+
+ let stats = compute(&settings, &[history], 10, 1).expect("failed to compute stats");
+ assert_eq!(stats.top.first().unwrap().0, vec!["echo"]);
+ }
+
+ #[test]
+ fn ignored_commands() {
+ let mut settings = Settings::new().unwrap();
+ settings.stats.ignored_commands.push("cd".to_string());
+
+ let history = [
+ History::import()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("cd foo")
+ .build()
+ .into(),
+ History::import()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("cargo build stuff")
+ .build()
+ .into(),
+ ];
+
+ let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats");
+ assert_eq!(stats.total_commands, 1);
+ assert_eq!(stats.unique_commands, 1);
+ }
+
+ #[test]
+ fn interesting_commands() {
+ let settings = Settings::new().unwrap();
+
+ assert_eq!(interesting_command(&settings, "cargo"), "cargo");
+ assert_eq!(
+ interesting_command(&settings, "cargo build foo bar"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo cargo build foo bar"),
+ "cargo build"
+ );
+ assert_eq!(interesting_command(&settings, "sudo"), "sudo");
+ }
+
+ // Test with spaces in the common_prefix
+ #[test]
+ fn interesting_commands_spaces() {
+ let mut settings = Settings::new().unwrap();
+ settings.stats.common_prefix.push("sudo test".to_string());
+
+ assert_eq!(interesting_command(&settings, "sudo test"), "sudo test");
+ assert_eq!(interesting_command(&settings, "sudo test "), "sudo test");
+ assert_eq!(interesting_command(&settings, "sudo test foo bar"), "foo");
+ assert_eq!(
+ interesting_command(&settings, "sudo test foo bar"),
+ "foo"
+ );
+
+ // Works with a common_subcommand as well
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build foo bar"),
+ "cargo build"
+ );
+
+ // We still match on just the sudo prefix
+ assert_eq!(interesting_command(&settings, "sudo"), "sudo");
+ assert_eq!(interesting_command(&settings, "sudo foo"), "foo");
+ }
+
+ // Test with spaces in the common_subcommand
+ #[test]
+ fn interesting_commands_spaces_subcommand() {
+ let mut settings = Settings::new().unwrap();
+ settings
+ .stats
+ .common_subcommands
+ .push("cargo build".to_string());
+
+ assert_eq!(interesting_command(&settings, "cargo build"), "cargo build");
+ assert_eq!(
+ interesting_command(&settings, "cargo build "),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "cargo build foo bar"),
+ "cargo build foo"
+ );
+
+ // Works with a common_prefix as well
+ assert_eq!(
+ interesting_command(&settings, "sudo cargo build foo bar"),
+ "cargo build foo"
+ );
+
+ // We still match on just cargo as a subcommand
+ assert_eq!(interesting_command(&settings, "cargo"), "cargo");
+ assert_eq!(interesting_command(&settings, "cargo foo"), "cargo foo");
+ }
+
+ // Test with spaces in the common_prefix and common_subcommand
+ #[test]
+ fn interesting_commands_spaces_both() {
+ let mut settings = Settings::new().unwrap();
+ settings.stats.common_prefix.push("sudo test".to_string());
+ settings
+ .stats
+ .common_subcommands
+ .push("cargo build".to_string());
+
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build "),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build foo bar"),
+ "cargo build foo"
+ );
+ }
+
+ #[test]
+ fn split_simple() {
+ assert_eq!(split_at_pipe("fd | rg"), ["fd ", " rg"]);
+ }
+
+ #[test]
+ fn split_multi() {
+ assert_eq!(
+ split_at_pipe("kubectl | jq | rg"),
+ ["kubectl ", " jq ", " rg"]
+ );
+ }
+
+ #[test]
+ fn split_simple_quoted() {
+ assert_eq!(
+ split_at_pipe("foo | bar 'baz {} | quux' | xyzzy"),
+ ["foo ", " bar 'baz {} | quux' ", " xyzzy"]
+ );
+ }
+
+ #[test]
+ fn split_multi_quoted() {
+ assert_eq!(
+ split_at_pipe("foo | bar 'baz \"{}\" | quux' | xyzzy"),
+ ["foo ", " bar 'baz \"{}\" | quux' ", " xyzzy"]
+ );
+ }
+
+ #[test]
+ fn escaped_pipes() {
+ assert_eq!(
+ split_at_pipe("foo | bar baz \\| quux"),
+ ["foo ", " bar baz \\| quux"]
+ );
+ }
+
+ #[test]
+ fn emoji() {
+ assert_eq!(
+ split_at_pipe("git commit -m \"🚀\""),
+ ["git commit -m \"🚀\""]
+ );
+ }
+
+ #[test]
+ fn starts_with_pipe() {
+ assert_eq!(
+ split_at_pipe("| sed 's/[0-9a-f]//g'"),
+ ["", " sed 's/[0-9a-f]//g'"]
+ );
+ }
+
+ #[test]
+ fn starts_with_spaces_and_pipe() {
+ assert_eq!(
+ split_at_pipe(" | sed 's/[0-9a-f]//g'"),
+ [" ", " sed 's/[0-9a-f]//g'"]
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_simple() {
+ assert_eq!(
+ strip_leading_env_vars("FOO=bar BAZ=quux echo foo"),
+ "echo foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_single() {
+ assert_eq!(strip_leading_env_vars("FOO='BAR=baz' echo foo"), "echo foo");
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_double() {
+ assert_eq!(
+ strip_leading_env_vars("FOO=\"BAR=baz\" echo foo"),
+ "echo foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_single_and_double() {
+ assert_eq!(
+ strip_leading_env_vars("FOO='BAR=\"baz\"' echo foo \"BAR=quux\""),
+ "echo foo \"BAR=quux\""
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_emojis() {
+ assert_eq!(
+ strip_leading_env_vars("FOO='BAR=🚀' echo foo \"BAR=quux\" foo"),
+ "echo foo \"BAR=quux\" foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_name_same_as_command() {
+ assert_eq!(strip_leading_env_vars("FOO='bar' bar baz"), "bar baz");
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/capture.rs b/crates/client/src/atuin_pty_proxy/capture.rs
new file mode 100644
index 00000000..55bc1788
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/capture.rs
@@ -0,0 +1,470 @@
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU16, Ordering};
+
+use crate::atuin_pty_proxy::osc133::{Event, Params, Parser, Zone};
+
+const HISTORY_ID_PARAM: &str = "history_id";
+const SESSION_ID_PARAM: &str = "session_id";
+const MAX_OUTPUT_CAPTURE_BYTES: usize = 1024 * 1024;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct CommandCapture {
+ pub(crate) prompt: String,
+ pub(crate) command: String,
+ pub(crate) output: String,
+ pub(crate) exit_code: Option<i32>,
+ pub(crate) history_id: Option<String>,
+ pub(crate) session_id: Option<String>,
+ pub(crate) output_truncated: bool,
+ pub(crate) output_observed_bytes: u64,
+}
+
+pub(crate) type CommandCaptureSink = Box<dyn Fn(CommandCapture) + Send + 'static>;
+
+#[derive(Default)]
+struct CaptureBuffers {
+ prompt: Vec<u8>,
+ command: Vec<u8>,
+ output: Vec<u8>,
+ output_observed_bytes: u64,
+ output_truncated: bool,
+ exit_code: Option<i32>,
+ history_id: Option<String>,
+ session_id: Option<String>,
+}
+
+pub(crate) struct CommandCaptureTracker {
+ parser: Parser,
+ zone: Zone,
+ buffers: CaptureBuffers,
+ cols: Arc<AtomicU16>,
+}
+
+impl CommandCaptureTracker {
+ pub(crate) fn new(cols: Arc<AtomicU16>) -> Self {
+ Self {
+ parser: Parser::new(),
+ zone: Zone::Unknown,
+ buffers: CaptureBuffers::default(),
+ cols,
+ }
+ }
+
+ pub(crate) fn push(&mut self, data: &[u8], mut on_capture: impl FnMut(CommandCapture)) {
+ let mut events = Vec::new();
+ self.parser
+ .push_located(data, |located| events.push(located));
+
+ let mut start = 0;
+ for located in events {
+ let marker_start = located.start_offset.min(data.len()).max(start);
+ let offset = located.offset.min(data.len());
+ self.append(&data[start..marker_start]);
+ self.handle_event(located.event, &located.params, &mut on_capture);
+ self.zone = located.zone;
+ start = offset;
+ }
+
+ let append_end = self
+ .parser
+ .incomplete_osc_sequence_start()
+ .map_or(data.len(), |sequence_start| {
+ sequence_start.min(data.len()).max(start)
+ });
+ if start < append_end {
+ self.append(&data[start..append_end]);
+ }
+ }
+
+ fn append(&mut self, data: &[u8]) {
+ match self.zone {
+ Zone::Prompt => self.buffers.prompt.extend_from_slice(data),
+ Zone::Input => self.buffers.command.extend_from_slice(data),
+ Zone::Output => self.append_output(data),
+ Zone::Unknown => {}
+ }
+ }
+
+ fn append_output(&mut self, data: &[u8]) {
+ self.buffers.output_observed_bytes = self
+ .buffers
+ .output_observed_bytes
+ .saturating_add(data.len() as u64);
+
+ if self.buffers.output_truncated {
+ return;
+ }
+
+ let remaining = MAX_OUTPUT_CAPTURE_BYTES.saturating_sub(self.buffers.output.len());
+ let retained = data.len().min(remaining);
+ self.buffers.output_truncated = retained < data.len();
+
+ if retained > 0 {
+ self.buffers.output.extend_from_slice(&data[..retained]);
+ }
+ }
+
+ fn handle_event(
+ &mut self,
+ event: Event,
+ params: &Params,
+ on_capture: &mut impl FnMut(CommandCapture),
+ ) {
+ match event {
+ Event::PromptStart => {
+ if self.zone != Zone::Prompt {
+ self.buffers = CaptureBuffers::default();
+ }
+ }
+ Event::CommandStart | Event::CommandExecuted => {}
+ Event::CommandFinished { exit_code } => {
+ let Some(history_id) = params.get(HISTORY_ID_PARAM).map(str::to_owned) else {
+ return;
+ };
+
+ if exit_code.is_some() || self.buffers.exit_code.is_none() {
+ self.buffers.exit_code = exit_code;
+ }
+ self.buffers.history_id = Some(history_id);
+ self.buffers.session_id = params.get(SESSION_ID_PARAM).map(str::to_owned);
+
+ if let Some(capture) = self.finish_capture() {
+ on_capture(capture);
+ }
+ }
+ }
+ }
+
+ fn finish_capture(&mut self) -> Option<CommandCapture> {
+ let buffers = std::mem::take(&mut self.buffers);
+ let cols = self.cols.load(Ordering::Relaxed).max(1);
+ let prompt = render_plain_text(&buffers.prompt, cols);
+ let command = render_plain_text(&buffers.command, cols)
+ .trim_matches(|c| c == '\r' || c == '\n')
+ .to_string();
+ let output = render_plain_text(&buffers.output, cols);
+ let output_truncated = buffers.output_truncated;
+ let output_observed_bytes = buffers.output_observed_bytes;
+ let exit_code = buffers.exit_code;
+ let history_id = buffers.history_id;
+ let session_id = buffers.session_id;
+
+ if command.is_empty() && output.is_empty() {
+ return None;
+ }
+
+ Some(CommandCapture {
+ prompt,
+ command,
+ output,
+ exit_code,
+ history_id,
+ session_id,
+ output_truncated,
+ output_observed_bytes,
+ })
+ }
+}
+
+const CLEAN_TEXT_MAX_ROWS: usize = 10_000;
+
+fn render_plain_text(bytes: &[u8], cols: u16) -> String {
+ if bytes.is_empty() {
+ return String::new();
+ }
+
+ let cols = cols.max(1);
+ let mut parser = vt100::Parser::new(estimated_rows(bytes, cols), cols, 0);
+ parser.process(bytes);
+ normalize_screen_contents(&parser.screen().contents())
+}
+
+fn normalize_screen_contents(contents: &str) -> String {
+ let mut lines = contents.lines().map(str::trim_end).collect::<Vec<_>>();
+ while lines.last().is_some_and(|line| line.is_empty()) {
+ lines.pop();
+ }
+ lines.join("\n")
+}
+
+#[expect(clippy::naive_bytecount, reason = "This is just an estimation")]
+fn estimated_rows(bytes: &[u8], cols: u16) -> u16 {
+ let newline_rows = bytes.iter().filter(|byte| **byte == b'\n').count() + 1;
+ let wrapped_rows = bytes.len() / cols as usize;
+ newline_rows
+ .saturating_add(wrapped_rows)
+ .saturating_add(1)
+ .clamp(1, CLEAN_TEXT_MAX_ROWS) as u16
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_pty_proxy::capture::render_plain_text;
+
+ use super::{Arc, AtomicU16, CommandCapture, CommandCaptureTracker, MAX_OUTPUT_CAPTURE_BYTES};
+
+ fn tracker(cols: u16) -> CommandCaptureTracker {
+ CommandCaptureTracker::new(Arc::new(AtomicU16::new(cols)))
+ }
+
+ fn assert_no_terminal_controls(text: &str) {
+ assert!(
+ !text
+ .chars()
+ .any(|ch| ch.is_control() && ch != '\n' && ch != '\t'),
+ "text still contains terminal controls: {text:?}"
+ );
+ }
+
+ #[test]
+ fn command_text_collapses_terminal_echo_edits() {
+ assert_eq!(render_plain_text(b"e\x08echo hi", 80), "echo hi");
+ assert_eq!(
+ render_plain_text(
+ b"e\x08echo\x08 \x08\x08 \x08\x08\x08e \x08\x08 \x08e\x08echo hi",
+ 80
+ ),
+ "echo hi"
+ );
+ assert_eq!(render_plain_text(b"echo hi", 80), "echo hi");
+ }
+
+ #[test]
+ fn text_cleaning_strips_ansi_and_terminal_controls() {
+ let text = render_plain_text(
+ b"\x1b[32mhi\x1b[0m\r\n% \r \r",
+ 80,
+ );
+
+ assert_eq!(text, "hi");
+ assert_no_terminal_controls(&text);
+ }
+
+ #[test]
+ fn text_cleaning_preserves_valid_utf8_after_backspace() {
+ let text = render_plain_text("🦀x\x08 \x08 crab".as_bytes(), 80);
+
+ assert_eq!(text, "🦀 crab");
+ assert_no_terminal_controls(&text);
+ }
+
+ #[test]
+ fn command_text_replays_backspaces() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ let input =
+ b"\x1b]133;A\x07$ \x1b]133;B\x07e\x08echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ";
+ tracker.push(input, |capture| captures.push(capture));
+
+ assert_eq!(captures.len(), 1);
+ assert_eq!(captures[0].command, "echo hi");
+ assert_eq!(captures[0].output, "hi");
+ assert_no_terminal_controls(&captures[0].command);
+ assert_no_terminal_controls(&captures[0].output);
+ }
+
+ #[test]
+ fn captures_complete_command() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;A\x07$ \x1b]133;B\x07echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ",
+ |capture| captures.push(capture),
+ );
+
+ assert_eq!(
+ captures,
+ vec![CommandCapture {
+ prompt: "$".to_string(),
+ command: "echo hi".to_string(),
+ output: "hi".to_string(),
+ exit_code: Some(0),
+ history_id: Some("hist".to_string()),
+ session_id: Some("sess".to_string()),
+ output_truncated: false,
+ output_observed_bytes: 4,
+ }]
+ );
+ }
+
+ #[test]
+ fn strips_ansi_and_split_markers() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(b"\x1b]133;A\x07\x1b[32m%\x1b[0m ", |_| {});
+ tracker.push(b"\x1b]133;B\x07ls\x1b]133;C", |_| {});
+ tracker.push(
+ b"\x07\x1b[31mfile\x1b[0m\r\n\x1b]133;D;1;history_id=hist;session_id=sess\x07\x1b]133;A\x07% ",
+ |capture| {
+ captures.push(capture);
+ },
+ );
+
+ assert_eq!(
+ captures,
+ vec![CommandCapture {
+ prompt: "%".to_string(),
+ command: "ls".to_string(),
+ output: "file".to_string(),
+ exit_code: Some(1),
+ history_id: Some("hist".to_string()),
+ session_id: Some("sess".to_string()),
+ output_truncated: false,
+ output_observed_bytes: 15,
+ }]
+ );
+ }
+
+ #[test]
+ fn duplicate_prompt_start_does_not_reset_prompt_capture() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;A\x07$ \x1b]133;A\x07continued \x1b]133;B\x07echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ",
+ |capture| captures.push(capture),
+ );
+
+ assert_eq!(captures.len(), 1);
+ assert_eq!(captures[0].prompt, "$ continued");
+ assert_eq!(captures[0].command, "echo hi");
+ assert_eq!(captures[0].output, "hi");
+ }
+
+ #[test]
+ fn bare_finish_without_metadata_is_ignored() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(b"\x1b]133;C\x07line one\r\n\x1b]133;D;0\x07", |capture| {
+ captures.push(capture);
+ });
+
+ tracker.push(b"\x1b]133;A\x07$ ", |capture| captures.push(capture));
+
+ assert!(captures.is_empty());
+ }
+
+ #[test]
+ fn bare_finish_before_metadata_in_same_push_ignored() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;C\x07line one\r\n\x1b]133;D;1\x07\x1b]133;D;0;history_id=018f;session_id=abcd\x07",
+ |capture| captures.push(capture),
+ );
+
+ assert_eq!(captures.len(), 1);
+ assert_eq!(captures[0].output, "line one");
+ assert_eq!(captures[0].exit_code, Some(0));
+ assert_eq!(captures[0].history_id.as_deref(), Some("018f"));
+ assert_eq!(captures[0].session_id.as_deref(), Some("abcd"));
+ }
+
+ #[test]
+ fn metadata_arriving_after_bare_finish_across_pushes() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(b"\x1b]133;C\x07line one\r\n\x1b]133;D;0\x07", |capture| {
+ captures.push(capture);
+ });
+ tracker.push(b"\x1b]133;D;0;history_id=018f", |capture| {
+ captures.push(capture)
+ });
+
+ assert!(captures.is_empty());
+
+ tracker.push(b";session_id=abcd\x07", |capture| captures.push(capture));
+
+ assert_eq!(captures.len(), 1);
+ assert_eq!(captures[0].output, "line one");
+ assert_eq!(captures[0].exit_code, Some(0));
+ assert_eq!(captures[0].history_id.as_deref(), Some("018f"));
+ assert_eq!(captures[0].session_id.as_deref(), Some("abcd"));
+ }
+
+ #[test]
+ fn split_finish_marker_is_not_counted_as_output() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;C\x07line one\r\n\x1b]133;D;0;history_id=018f",
+ |capture| {
+ captures.push(capture);
+ },
+ );
+ assert!(captures.is_empty());
+
+ tracker.push(b";session_id=abcd\x07", |capture| captures.push(capture));
+
+ assert_eq!(captures.len(), 1);
+ assert_eq!(captures[0].output, "line one");
+ assert_eq!(captures[0].output_observed_bytes, 10);
+ }
+
+ #[test]
+ fn captures_output_with_history_metadata_from_d_marker() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;C\x07line one\r\n\x1b]133;D;0;history_id=018f;session_id=abcd\x07",
+ |capture| captures.push(capture),
+ );
+
+ assert_eq!(
+ captures,
+ vec![CommandCapture {
+ prompt: String::new(),
+ command: String::new(),
+ output: "line one".to_string(),
+ exit_code: Some(0),
+ history_id: Some("018f".to_string()),
+ session_id: Some("abcd".to_string()),
+ output_truncated: false,
+ output_observed_bytes: 10,
+ }]
+ );
+ }
+
+ #[test]
+ fn output_capture_is_capped_and_reports_observed_bytes() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+ let mut input = b"\x1b]133;C\x07".to_vec();
+ input.extend(std::iter::repeat_n(b'x', MAX_OUTPUT_CAPTURE_BYTES + 10));
+ input.extend_from_slice(b"\x1b]133;D;0;history_id=big;session_id=session-1\x07");
+
+ tracker.push(&input, |capture| captures.push(capture));
+
+ assert_eq!(captures.len(), 1);
+ assert!(captures[0].output_truncated);
+ assert_eq!(
+ captures[0].output_observed_bytes,
+ (MAX_OUTPUT_CAPTURE_BYTES + 10) as u64
+ );
+ }
+
+ #[test]
+ fn resets_buffers_between_c_d_only_captures() {
+ let mut tracker = tracker(80);
+ let mut captures = Vec::new();
+
+ tracker.push(
+ b"\x1b]133;C\x07first\r\n\x1b]133;D;0;history_id=one\x07\x1b]133;C\x07second\r\n\x1b]133;D;1;history_id=two\x07",
+ |capture| captures.push(capture),
+ );
+
+ assert_eq!(captures.len(), 2);
+ assert_eq!(captures[0].output, "first");
+ assert_eq!(captures[0].history_id.as_deref(), Some("one"));
+ assert_eq!(captures[1].output, "second");
+ assert_eq!(captures[1].history_id.as_deref(), Some("two"));
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/debug.rs b/crates/client/src/atuin_pty_proxy/debug.rs
new file mode 100644
index 00000000..c2a1691c
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/debug.rs
@@ -0,0 +1,53 @@
+use crate::atuin_pty_proxy::osc133::{Event, Parser};
+
+pub(crate) const RESET: &[u8] = b"\x1b[0m";
+
+pub(crate) struct Osc133DebugHighlighter {
+ parser: Parser,
+}
+
+impl Osc133DebugHighlighter {
+ pub(crate) fn new() -> Self {
+ Self {
+ parser: Parser::new(),
+ }
+ }
+
+ pub(crate) fn render(&mut self, data: &[u8]) -> Vec<u8> {
+ let mut events = Vec::new();
+ self.parser
+ .push_located(data, |located| events.push(located));
+
+ if events.is_empty() {
+ return data.to_vec();
+ }
+
+ let mut rendered = Vec::with_capacity(data.len() + (events.len() * 64));
+ let mut start = 0;
+
+ for located in events {
+ let offset = located.offset.min(data.len());
+ if offset > start {
+ rendered.extend_from_slice(&data[start..offset]);
+ }
+
+ rendered.extend_from_slice(event_label(located.event));
+ rendered.extend_from_slice(RESET);
+ start = offset;
+ }
+
+ rendered.extend_from_slice(&data[start..]);
+ rendered
+ }
+}
+
+fn event_label(event: Event) -> &'static [u8] {
+ match event {
+ Event::PromptStart => b"\x1b[1;37;45m[OSC133:A prompt]\x1b[0m",
+ Event::CommandStart => b"\x1b[1;30;43m[OSC133:B input]\x1b[0m",
+ Event::CommandExecuted => b"\x1b[1;30;46m[OSC133:C output]\x1b[0m",
+ Event::CommandFinished { exit_code: Some(0) } => b"\x1b[1;37;42m[OSC133:D exit=0]\x1b[0m",
+ Event::CommandFinished { exit_code: Some(_) } => b"\x1b[1;37;41m[OSC133:D exit!=0]\x1b[0m",
+ Event::CommandFinished { exit_code: None } => b"\x1b[1;37;44m[OSC133:D exit=?]\x1b[0m",
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/mod.rs b/crates/client/src/atuin_pty_proxy/mod.rs
new file mode 100644
index 00000000..e1d01c83
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/mod.rs
@@ -0,0 +1,17 @@
+#[cfg(unix)]
+mod capture;
+#[cfg(unix)]
+mod debug;
+#[cfg(unix)]
+mod osc133;
+#[cfg(unix)]
+mod pty_proxy;
+#[cfg(unix)]
+mod runtime;
+#[cfg(unix)]
+mod screen;
+
+#[cfg(unix)]
+pub(crate) use capture::{CommandCapture, CommandCaptureSink};
+#[cfg(unix)]
+pub(crate) use pty_proxy::PtyProxy;
diff --git a/crates/client/src/atuin_pty_proxy/osc133.rs b/crates/client/src/atuin_pty_proxy/osc133.rs
new file mode 100644
index 00000000..f2a796f1
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/osc133.rs
@@ -0,0 +1,899 @@
+//! Streaming parser for OSC 133 ([`FinalTerm`] semantic prompt) escape sequences.
+//!
+//! OSC 133 marks four regions of a shell interaction:
+//!
+//! | Marker | Meaning |
+//! |--------|--------------------------------------|
+//! | A | Prompt is about to be printed |
+//! | B | Prompt ended — command input begins |
+//! | C | Command submitted — output begins |
+//! | D[;n] | Command finished with exit code *n* |
+//!
+//! The wire format is `ESC ] 133 ; <cmd> [; <params>] ST` where ST is BEL
+//! (0x07), ESC \ (0x1B 0x5C), or C1 ST (0x9C).
+//!
+//! # Design goals
+//!
+//! * **Transparent** — the parser observes the byte stream without modifying it;
+//! the caller remains responsible for forwarding bytes to their destination.
+//! * **Bounded** — OSC parameter buffering is capped so malformed output cannot
+//! grow memory without limit.
+//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available
+//! and returns immediately.
+//! * **Extensible** — marker parameters are preserved so Atuin-specific metadata
+//! can ride alongside standard OSC 133 markers.
+
+/// Events emitted when an OSC 133 marker is detected.
+#[derive(Debug, Clone, PartialEq, Eq, Copy)]
+pub(crate) enum Event {
+ /// `ESC ] 133 ; A ST` — the shell is about to display its prompt.
+ PromptStart,
+ /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command.
+ CommandStart,
+ /// `ESC ] 133 ; C ST` — the command has been submitted for execution.
+ CommandExecuted,
+ /// `ESC ] 133 ; D [; <exit_code>] ST` — command output is complete.
+ CommandFinished {
+ /// The exit code reported after the `;`, if present and valid.
+ exit_code: Option<i32>,
+ },
+}
+
+/// Parameters attached to an OSC 133 marker.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub(crate) struct Params {
+ items: Vec<Param>,
+}
+
+impl Params {
+ /// Iterate over all marker parameters in order.
+ #[cfg(test)]
+ #[inline]
+ pub(crate) fn iter(&self) -> impl Iterator<Item = &Param> {
+ self.items.iter()
+ }
+
+ /// Return the value for the first `key=value` parameter with this key.
+ #[inline]
+ pub(crate) fn get(&self, key: &str) -> Option<&str> {
+ self.items.iter().find_map(|item| match item {
+ Param::KeyValue {
+ key: item_key,
+ value,
+ } if item_key == key => Some(value.as_str()),
+ Param::Value(_) | Param::KeyValue { .. } => None,
+ })
+ }
+}
+
+/// A single OSC 133 marker parameter.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum Param {
+ /// A positional parameter without an equals sign.
+ Value(String),
+ /// A `key=value` parameter.
+ KeyValue { key: String, value: String },
+}
+
+/// An OSC 133 event with its position in the most recent input chunk.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct LocatedEvent {
+ /// The OSC 133 event that was parsed.
+ pub(crate) event: Event,
+ /// Offset where this marker starts in the current chunk.
+ ///
+ /// If a marker started in an earlier [`Parser::push_located`] call, this is
+ /// `0` in the chunk that completed the marker.
+ pub(crate) start_offset: usize,
+ /// Offset immediately after this marker's terminator in the current chunk.
+ ///
+ /// If a marker spans multiple [`Parser::push_located`] calls, this is still
+ /// the offset in the chunk that completed the marker.
+ pub(crate) offset: usize,
+ /// The semantic zone after applying this event.
+ pub(crate) zone: Zone,
+ /// Metadata parameters attached to this marker.
+ pub(crate) params: Params,
+}
+
+/// The current semantic zone as determined by the most recent OSC 133 marker.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Zone {
+ /// No marker seen yet, or after a `D` marker (between commands).
+ #[default]
+ Unknown,
+ /// Between `A` and `B` — the shell is rendering its prompt.
+ Prompt,
+ /// Between `B` and `C` — the user is editing a command line.
+ Input,
+ /// Between `C` and `D` — command output is being produced.
+ Output,
+}
+
+// ---------------------------------------------------------------------------
+// Internal constants
+// ---------------------------------------------------------------------------
+
+const ESC: u8 = 0x1B;
+const BEL: u8 = 0x07;
+const C1_ST: u8 = 0x9C;
+const BACKSLASH: u8 = b'\\';
+const RIGHT_BRACKET: u8 = b']';
+
+/// Maximum bytes we'll buffer for the OSC parameter string. This is large enough
+/// for Atuin metadata such as history/session IDs while still bounding malformed
+/// OSC sequences.
+const PARAM_BUF_CAP: usize = 512;
+
+// ---------------------------------------------------------------------------
+// State machine
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum State {
+ /// Normal pass-through.
+ Ground,
+ /// Saw ESC (0x1B).
+ Esc,
+ /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes.
+ OscParam,
+ /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \`
+ /// (string terminator) or something else.
+ OscEsc,
+}
+
+/// A streaming, zero-allocation parser for OSC 133 escape sequences.
+///
+/// Feed arbitrary byte slices into [`Parser::push`]. The parser detects
+/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback
+/// without modifying the data. It can sit transparently between a PTY reader
+/// and stdout.
+pub(crate) struct Parser {
+ state: State,
+ zone: Zone,
+ sequence_start: Option<usize>,
+ param_buf: [u8; PARAM_BUF_CAP],
+ param_len: usize,
+}
+
+impl Default for Parser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl Parser {
+ /// Create a new parser in the initial (ground / unknown-zone) state.
+ #[inline]
+ pub(crate) fn new() -> Self {
+ Self {
+ state: State::Ground,
+ zone: Zone::Unknown,
+ sequence_start: None,
+ param_buf: [0u8; PARAM_BUF_CAP],
+ param_len: 0,
+ }
+ }
+
+ /// The current semantic zone based on markers seen so far.
+ #[inline]
+ #[expect(dead_code)]
+ pub(crate) fn zone(&self) -> Zone {
+ self.zone
+ }
+
+ /// Start offset of an incomplete OSC sequence in the most recent chunk.
+ #[inline]
+ pub(crate) fn incomplete_osc_sequence_start(&self) -> Option<usize> {
+ matches!(self.state, State::OscParam | State::OscEsc)
+ .then(|| self.sequence_start.unwrap_or(0))
+ }
+
+ /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker
+ /// found.
+ ///
+ /// All bytes in `data` should still be forwarded to the terminal by the
+ /// caller — this method only *observes* the stream.
+ #[cfg(test)]
+ #[inline]
+ pub(crate) fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) {
+ self.push_located(data, |located| on_event(located.event));
+ }
+
+ /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker
+ /// found with its byte offset in this chunk.
+ ///
+ /// The offset points to the first byte after the marker terminator, making
+ /// it suitable for callers that need to split the original chunk at marker
+ /// boundaries.
+ #[inline]
+ pub(crate) fn push_located(&mut self, data: &[u8], mut on_event: impl FnMut(LocatedEvent)) {
+ self.sequence_start = (self.state != State::Ground).then_some(0);
+
+ for (offset, &byte) in data.iter().enumerate() {
+ match self.state {
+ State::Ground => {
+ if byte == ESC {
+ self.state = State::Esc;
+ self.sequence_start = Some(offset);
+ }
+ }
+ State::Esc => {
+ if byte == RIGHT_BRACKET {
+ self.state = State::OscParam;
+ self.param_len = 0;
+ } else {
+ self.state = State::Ground;
+ self.sequence_start = None;
+ }
+ }
+ State::OscParam => {
+ if byte == BEL || byte == C1_ST {
+ self.dispatch(offset + 1, &mut on_event);
+ self.state = State::Ground;
+ self.sequence_start = None;
+ } else if byte == ESC {
+ self.state = State::OscEsc;
+ } else if self.param_len < PARAM_BUF_CAP {
+ self.param_buf[self.param_len] = byte;
+ self.param_len += 1;
+ }
+ // If param_len == PARAM_BUF_CAP we silently stop
+ // accumulating — dispatch will ignore non-133 sequences.
+ }
+ State::OscEsc => {
+ if byte == BACKSLASH {
+ self.dispatch(offset + 1, &mut on_event);
+ }
+ // Whether we got a valid ST or not, return to ground.
+ // (A new ESC ] would restart accumulation via the Ground
+ // -> Esc -> OscParam path on the *next* byte.)
+ self.state = State::Ground;
+ self.sequence_start = None;
+ }
+ }
+ }
+ }
+
+ /// Inspect the accumulated parameter buffer. If it holds an OSC 133
+ /// payload, emit the corresponding [`Event`] and update the zone.
+ #[inline]
+ fn dispatch(&mut self, offset: usize, on_event: &mut impl FnMut(LocatedEvent)) {
+ let payload = &self.param_buf[..self.param_len];
+
+ if payload.len() < 5 || &payload[..4] != b"133;" {
+ return;
+ }
+
+ if payload.len() > 5 && payload[5] != b';' {
+ return;
+ }
+
+ let metadata = payload.get(6..).unwrap_or_default();
+ let cmd = payload[4];
+ let (event, params) = match cmd {
+ b'A' => {
+ self.zone = Zone::Prompt;
+ (Event::PromptStart, parse_params(metadata))
+ }
+ b'B' => {
+ self.zone = Zone::Input;
+ (Event::CommandStart, parse_params(metadata))
+ }
+ b'C' => {
+ self.zone = Zone::Output;
+ (Event::CommandExecuted, parse_params(metadata))
+ }
+ b'D' => {
+ let (exit_code, params) = parse_command_finished_params(metadata);
+ self.zone = Zone::Unknown;
+ (Event::CommandFinished { exit_code }, params)
+ }
+ _ => return,
+ };
+
+ on_event(LocatedEvent {
+ event,
+ start_offset: self.sequence_start.unwrap_or(0),
+ offset,
+ zone: self.zone,
+ params,
+ });
+ }
+}
+
+fn parse_command_finished_params(metadata: &[u8]) -> (Option<i32>, Params) {
+ if metadata.is_empty() {
+ return (None, Params::default());
+ }
+
+ let Some(separator) = metadata.iter().position(|byte| *byte == b';') else {
+ return parse_exit_code(metadata).map_or_else(
+ || (None, parse_params(metadata)),
+ |exit_code| (Some(exit_code), Params::default()),
+ );
+ };
+
+ let (first, rest) = metadata.split_at(separator);
+ let rest = &rest[1..];
+
+ parse_exit_code(first).map_or_else(
+ || (None, parse_params(metadata)),
+ |exit_code| (Some(exit_code), parse_params(rest)),
+ )
+}
+
+fn parse_exit_code(code: &[u8]) -> Option<i32> {
+ if code.is_empty() {
+ return None;
+ }
+
+ std::str::from_utf8(code)
+ .ok()
+ .and_then(|code| code.parse::<i32>().ok())
+}
+
+fn parse_params(metadata: &[u8]) -> Params {
+ let items = metadata
+ .split(|byte| *byte == b';')
+ .filter(|part| !part.is_empty())
+ .map(parse_param)
+ .collect();
+
+ Params { items }
+}
+
+fn parse_param(param: &[u8]) -> Param {
+ let param = String::from_utf8_lossy(param);
+
+ if let Some((key, value)) = param.split_once('=') {
+ return Param::KeyValue {
+ key: key.to_string(),
+ value: value.to_string(),
+ };
+ }
+
+ Param::Value(param.into_owned())
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::{BEL, Event, LocatedEvent, Param, Params, Parser, Zone};
+
+ /// Collect all events from a single `push` call.
+ fn parse_events(data: &[u8]) -> Vec<Event> {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+ parser.push(data, |e| events.push(e));
+ events
+ }
+
+ // -- Basic event detection ------------------------------------------------
+
+ #[test]
+ fn detect_prompt_start_bel() {
+ let data = b"\x1b]133;A\x07";
+ assert_eq!(parse_events(data), vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn detect_prompt_start_st() {
+ let data = b"\x1b]133;A\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn detect_command_start_bel() {
+ let data = b"\x1b]133;B\x07";
+ assert_eq!(parse_events(data), vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn detect_command_start_st() {
+ let data = b"\x1b]133;B\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn detect_command_executed_bel() {
+ let data = b"\x1b]133;C\x07";
+ assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
+ }
+
+ #[test]
+ fn detect_command_executed_st() {
+ let data = b"\x1b]133;C\x1b\\";
+ assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
+ }
+
+ #[test]
+ fn detect_command_finished_no_exit_code() {
+ let data = b"\x1b]133;D\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_zero() {
+ let data = b"\x1b]133;D;0\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: Some(0) }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_nonzero() {
+ let data = b"\x1b]133;D;127\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(127)
+ }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_negative_exit_code() {
+ let data = b"\x1b]133;D;-1\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(-1)
+ }]
+ );
+ }
+
+ #[test]
+ fn detect_command_finished_exit_code_st() {
+ let data = b"\x1b]133;D;42\x1b\\";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(42)
+ }]
+ );
+ }
+
+ #[test]
+ fn invalid_exit_code_yields_none() {
+ let data = b"\x1b]133;D;abc\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ // -- Zone tracking --------------------------------------------------------
+
+ #[test]
+ fn zone_starts_unknown() {
+ let parser = Parser::new();
+ assert_eq!(parser.zone(), Zone::Unknown);
+ }
+
+ #[test]
+ fn full_zone_cycle() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;A\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Prompt);
+
+ parser.push(b"\x1b]133;B\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Input);
+
+ parser.push(b"\x1b]133;C\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Output);
+
+ parser.push(b"\x1b]133;D;0\x07", |e| events.push(e));
+ assert_eq!(parser.zone(), Zone::Unknown);
+
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(0) },
+ ]
+ );
+ }
+
+ // -- Multiple events in one push ------------------------------------------
+
+ #[test]
+ fn multiple_events_single_push() {
+ let data = b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file.txt\n\x1b]133;D;0\x07";
+ let events = parse_events(data);
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(0) },
+ ]
+ );
+ }
+
+ // -- Split across push boundaries -----------------------------------------
+
+ #[test]
+ fn split_esc_and_bracket() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"]133;A\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn split_mid_param() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]13", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"3;D;42\x07", |e| events.push(e));
+ assert_eq!(
+ events,
+ vec![Event::CommandFinished {
+ exit_code: Some(42)
+ }]
+ );
+ }
+
+ #[test]
+ fn split_before_terminator() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;B", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::CommandStart]);
+ }
+
+ #[test]
+ fn split_esc_backslash_terminator() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b]133;C\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ parser.push(b"\\", |e| events.push(e));
+ assert_eq!(events, vec![Event::CommandExecuted]);
+ }
+
+ // -- Interleaved normal text ----------------------------------------------
+
+ #[test]
+ fn normal_text_before_and_after() {
+ let data = b"hello world\x1b]133;A\x07prompt text\x1b]133;B\x07command";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- Non-133 OSC sequences (should be ignored) ----------------------------
+
+ #[test]
+ fn non_133_osc_ignored() {
+ let data = b"\x1b]0;window title\x07\x1b]133;A\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn osc_7_ignored() {
+ let data = b"\x1b]7;file:///home/user\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Unknown command letter -----------------------------------------------
+
+ #[test]
+ fn unknown_command_ignored() {
+ let data = b"\x1b]133;Z\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ #[test]
+ fn marker_with_unexpected_trailing_bytes_ignored() {
+ let data = b"\x1b]133;ABC\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Malformed sequences --------------------------------------------------
+
+ #[test]
+ fn esc_followed_by_non_bracket() {
+ let data = b"\x1b[31m\x1b]133;A\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn lone_esc_at_end_of_chunk() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push(b"\x1b", |e| events.push(e));
+ assert!(events.is_empty());
+
+ // Feed non-bracket to abort the escape, then a real sequence.
+ parser.push(b"x\x1b]133;A\x07", |e| events.push(e));
+ assert_eq!(events, vec![Event::PromptStart]);
+ }
+
+ #[test]
+ fn truncated_133_prefix() {
+ // "13" followed by terminator — not "133;" so no event.
+ let data = b"\x1b]13\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ #[test]
+ fn empty_osc() {
+ let data = b"\x1b]\x07";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Buffer overflow (very long non-133 OSC) ------------------------------
+
+ #[test]
+ fn very_long_osc_does_not_panic() {
+ let mut data = Vec::new();
+ data.extend_from_slice(b"\x1b]");
+ data.extend(std::iter::repeat_n(b'x', 1000));
+ data.push(BEL);
+ // Should not panic and should produce no event.
+ assert!(parse_events(&data).is_empty());
+ }
+
+ // -- Empty input ----------------------------------------------------------
+
+ #[test]
+ fn empty_input() {
+ assert!(parse_events(b"").is_empty());
+ }
+
+ #[test]
+ fn only_normal_text() {
+ let data = b"just some regular terminal output\r\n";
+ assert!(parse_events(data).is_empty());
+ }
+
+ // -- Repeated prompts (empty command) ------------------------------------
+
+ #[test]
+ fn repeated_prompt_cycle() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ // User hits enter on an empty prompt twice.
+ let data = b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;D\x07\x1b]133;A\x07$ \x1b]133;B\x07";
+ parser.push(data, |e| events.push(e));
+
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandFinished { exit_code: None },
+ Event::PromptStart,
+ Event::CommandStart,
+ ]
+ );
+ assert_eq!(parser.zone(), Zone::Input);
+ }
+
+ // -- Byte-at-a-time feeding -----------------------------------------------
+
+ #[test]
+ fn byte_at_a_time() {
+ let data = b"\x1b]133;D;99\x07";
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ for &byte in data {
+ parser.push(&[byte], |e| events.push(e));
+ }
+
+ assert_eq!(
+ events,
+ vec![Event::CommandFinished {
+ exit_code: Some(99)
+ }]
+ );
+ }
+
+ // -- Mixed terminators ----------------------------------------------------
+
+ #[test]
+ fn mixed_bel_and_st_terminators() {
+ let data = b"\x1b]133;A\x07\x1b]133;B\x1b\\\x1b]133;C\x07\x1b]133;D;1\x1b\\";
+ let events = parse_events(data);
+ assert_eq!(
+ events,
+ vec![
+ Event::PromptStart,
+ Event::CommandStart,
+ Event::CommandExecuted,
+ Event::CommandFinished { exit_code: Some(1) },
+ ]
+ );
+ }
+
+ #[test]
+ fn detects_c1_st_terminator() {
+ let data = b"\x1b]133;A\x9c";
+ assert_eq!(parse_events(data), vec![Event::PromptStart]);
+ }
+
+ // -- Located event offsets ------------------------------------------------
+
+ #[test]
+ fn located_event_reports_offset_after_marker() {
+ let data = b"before\x1b]133;A\x07prompt";
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push_located(data, |e| events.push(e));
+
+ assert_eq!(
+ events,
+ vec![LocatedEvent {
+ event: Event::PromptStart,
+ start_offset: b"before".len(),
+ offset: b"before\x1b]133;A\x07".len(),
+ zone: Zone::Prompt,
+ params: Params::default(),
+ }]
+ );
+ }
+
+ #[test]
+ fn located_event_offset_is_relative_to_completing_chunk() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push_located(b"\x1b]133;", |e| events.push(e));
+ parser.push_located(b"D;42\x07after", |e| events.push(e));
+
+ assert_eq!(
+ events,
+ vec![LocatedEvent {
+ event: Event::CommandFinished {
+ exit_code: Some(42)
+ },
+ start_offset: 0,
+ offset: b"D;42\x07".len(),
+ zone: Zone::Unknown,
+ params: Params::default(),
+ }]
+ );
+ }
+
+ #[test]
+ fn located_event_preserves_metadata_params() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push_located(
+ b"\x1b]133;D;127;history_id=018f;session_id=abcd;flag\x07",
+ |event| events.push(event),
+ );
+
+ assert_eq!(events.len(), 1);
+ let event = &events[0];
+ assert_eq!(
+ event.event,
+ Event::CommandFinished {
+ exit_code: Some(127)
+ }
+ );
+ assert_eq!(event.params.get("history_id"), Some("018f"));
+ assert_eq!(event.params.get("session_id"), Some("abcd"));
+ assert!(
+ event
+ .params
+ .iter()
+ .any(|param| param == &Param::Value("flag".to_string()))
+ );
+ }
+
+ #[test]
+ fn command_finished_metadata_without_exit_code_is_preserved() {
+ let mut parser = Parser::new();
+ let mut events = Vec::new();
+
+ parser.push_located(b"\x1b]133;D;history_id=018f;session_id=abcd\x07", |event| {
+ events.push(event);
+ });
+
+ assert_eq!(events.len(), 1);
+ let event = &events[0];
+ assert_eq!(event.event, Event::CommandFinished { exit_code: None });
+ assert_eq!(event.params.get("history_id"), Some("018f"));
+ assert_eq!(event.params.get("session_id"), Some("abcd"));
+ }
+
+ // -- Default trait --------------------------------------------------------
+
+ #[test]
+ fn parser_default() {
+ let parser = Parser::default();
+ assert_eq!(parser.zone(), Zone::Unknown);
+ }
+
+ #[test]
+ fn zone_default() {
+ assert_eq!(Zone::default(), Zone::Unknown);
+ }
+
+ // -- D with empty exit code field -----------------------------------------
+
+ #[test]
+ fn d_with_semicolon_but_empty_code() {
+ // "133;D;" — semicolon present but no digits.
+ let data = b"\x1b]133;D;\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+
+ // -- Consecutive OSC sequences without gap --------------------------------
+
+ #[test]
+ fn back_to_back_osc_no_gap() {
+ let data = b"\x1b]133;A\x07\x1b]133;B\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- CSI sequences interleaved (should not confuse parser) ----------------
+
+ #[test]
+ fn csi_sequences_ignored() {
+ // CSI (ESC [) color codes mixed with OSC 133.
+ let data = b"\x1b[32m\x1b]133;A\x07\x1b[0m$ \x1b]133;B\x07";
+ let events = parse_events(data);
+ assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
+ }
+
+ // -- Large exit codes -----------------------------------------------------
+
+ #[test]
+ fn large_exit_code() {
+ let data = b"\x1b]133;D;2147483647\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished {
+ exit_code: Some(i32::MAX)
+ }]
+ );
+ }
+
+ #[test]
+ fn overflow_exit_code_yields_none() {
+ let data = b"\x1b]133;D;9999999999999\x07";
+ assert_eq!(
+ parse_events(data),
+ vec![Event::CommandFinished { exit_code: None }]
+ );
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/pty_proxy.rs b/crates/client/src/atuin_pty_proxy/pty_proxy.rs
new file mode 100644
index 00000000..ef4b0c37
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/pty_proxy.rs
@@ -0,0 +1,237 @@
+use clap::{Args, Subcommand, ValueEnum};
+
+use crate::atuin_pty_proxy::{CommandCaptureSink, runtime};
+
+#[derive(Args, Debug)]
+pub(crate) struct PtyProxy {
+ /// Highlight OSC 133 prompt, input, output, and exit-code regions
+ #[arg(long)]
+ debug_osc133: bool,
+
+ #[command(subcommand)]
+ cmd: Option<Cmd>,
+}
+
+#[derive(Subcommand, Debug)]
+pub(crate) enum Cmd {
+ /// Print shell code to initialize atuin pty-proxy on shell startup
+ Init(Init),
+}
+
+#[derive(Args, Debug)]
+pub(crate) struct Init {
+ /// Shell to generate init for. If omitted, attempt auto-detection
+ #[arg(value_enum)]
+ shell: Option<Shell>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+#[value(rename_all = "lower")]
+enum Shell {
+ /// Zsh setup
+ Zsh,
+
+ /// Bash setup
+ Bash,
+
+ /// Fish setup
+ Fish,
+
+ /// Nu setup
+ Nu,
+}
+
+pub(crate) struct RuntimeOptions {
+ pub(crate) debug_osc133: bool,
+ pub(crate) command_capture_sink: Option<CommandCaptureSink>,
+}
+
+impl RuntimeOptions {
+ fn new(debug_osc133: bool, command_capture_sink: Option<CommandCaptureSink>) -> Self {
+ Self {
+ debug_osc133: debug_osc133 || env_flag("ATUIN_PTY_PROXY_DEBUG"),
+ command_capture_sink,
+ }
+ }
+}
+
+impl PtyProxy {
+ pub(crate) fn run(self, command_capture_sink: Option<CommandCaptureSink>) {
+ match self.cmd {
+ Some(Cmd::Init(init)) => {
+ if let Err(err) = init.run() {
+ eprintln!("atuin pty-proxy: {err}");
+ std::process::exit(1);
+ }
+ }
+ None => runtime::main(RuntimeOptions::new(self.debug_osc133, command_capture_sink)),
+ }
+ }
+}
+
+impl Init {
+ fn run(self) -> Result<(), String> {
+ let shell = detect_shell(self.shell)?;
+ let script = render_init(shell);
+ print!("{script}");
+ Ok(())
+ }
+}
+
+fn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> {
+ if let Some(shell) = cli_shell {
+ return Ok(shell);
+ }
+
+ if let Ok(shell) = std::env::var("ATUIN_SHELL")
+ && let Some(shell) = shell_from_name(&shell)
+ {
+ return Ok(shell);
+ }
+
+ if let Ok(shell) = std::env::var("SHELL")
+ && let Some(shell) = shell_from_name(&shell)
+ {
+ return Ok(shell);
+ }
+
+ Err(
+ "could not detect a supported shell. Please specify one explicitly: bash, zsh, fish, or nu"
+ .to_string(),
+ )
+}
+
+fn shell_from_name(name: &str) -> Option<Shell> {
+ let shell = name
+ .trim()
+ .rsplit('/')
+ .next()
+ .unwrap_or(name)
+ .trim_start_matches('-')
+ .to_ascii_lowercase();
+
+ match shell.as_str() {
+ "bash" => Some(Shell::Bash),
+ "zsh" => Some(Shell::Zsh),
+ "fish" => Some(Shell::Fish),
+ "nu" => Some(Shell::Nu),
+ _ => None,
+ }
+}
+
+fn env_flag(name: &str) -> bool {
+ std::env::var(name).is_ok_and(|value| {
+ matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "1" | "true" | "yes" | "on"
+ )
+ })
+}
+
+#[expect(
+ clippy::literal_string_with_formatting_args,
+ reason = "It's shell code"
+)]
+fn render_init(shell: Shell) -> &'static str {
+ match shell {
+ Shell::Bash | Shell::Zsh => {
+ r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
+ _atuin_pty_proxy_tmux_current="${TMUX:-}"
+ _atuin_pty_proxy_tmux_previous="${ATUIN_PTY_PROXY_TMUX:-}"
+
+ if [[ -z "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || [[ "$_atuin_pty_proxy_tmux_current" != "$_atuin_pty_proxy_tmux_previous" ]]; then
+ export ATUIN_PTY_PROXY_ACTIVE=1
+ export ATUIN_PTY_PROXY_TMUX="$_atuin_pty_proxy_tmux_current"
+ exec atuin pty-proxy
+ fi
+
+ unset _atuin_pty_proxy_tmux_current _atuin_pty_proxy_tmux_previous
+fi
+"#
+ }
+ Shell::Fish => {
+ r#"if status is-interactive; and test -t 0; and test -t 1
+ set -l _atuin_pty_proxy_tmux_current ""
+ if set -q TMUX
+ set _atuin_pty_proxy_tmux_current "$TMUX"
+ end
+
+ set -l _atuin_pty_proxy_tmux_previous ""
+ if set -q ATUIN_PTY_PROXY_TMUX
+ set _atuin_pty_proxy_tmux_previous "$ATUIN_PTY_PROXY_TMUX"
+ end
+
+ if not set -q ATUIN_PTY_PROXY_ACTIVE
+ set -gx ATUIN_PTY_PROXY_ACTIVE 1
+ set -gx ATUIN_PTY_PROXY_TMUX "$_atuin_pty_proxy_tmux_current"
+ exec atuin pty-proxy
+ else if test "$_atuin_pty_proxy_tmux_current" != "$_atuin_pty_proxy_tmux_previous"
+ set -gx ATUIN_PTY_PROXY_ACTIVE 1
+ set -gx ATUIN_PTY_PROXY_TMUX "$_atuin_pty_proxy_tmux_current"
+ exec atuin pty-proxy
+ end
+end
+"#
+ }
+ // Nushell cannot dynamically source the output of `atuin init nu`,
+ // so we only output the pty-proxy preamble here. Users must also set up
+ // `atuin init nu` separately.
+ Shell::Nu => {
+ r#"if (is-terminal --stdin) and (is-terminal --stdout) {
+ let tmux_current = ($env.TMUX? | default "")
+ let tmux_previous = ($env.ATUIN_PTY_PROXY_TMUX? | default "")
+
+ if (($env.ATUIN_PTY_PROXY_ACTIVE? | default "") | is-empty) or ($tmux_current != $tmux_previous) {
+ $env.ATUIN_PTY_PROXY_ACTIVE = "1"
+ $env.ATUIN_PTY_PROXY_TMUX = $tmux_current
+ exec atuin pty-proxy
+ }
+}
+"#
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Shell, render_init, shell_from_name};
+
+ #[test]
+ fn shell_from_name_handles_paths() {
+ assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh));
+ assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash));
+ assert_eq!(shell_from_name("fish"), Some(Shell::Fish));
+ assert_eq!(shell_from_name("nu"), Some(Shell::Nu));
+ }
+
+ #[test]
+ fn posix_init_uses_exec_and_tmux_guard() {
+ let script = render_init(Shell::Bash);
+ assert!(script.contains("exec atuin pty-proxy"));
+ assert!(script.contains("ATUIN_PTY_PROXY_TMUX"));
+ assert!(!script.contains("eval \"$(atuin init bash)\""));
+ }
+
+ #[test]
+ fn posix_init_has_no_double_braces() {
+ let script = render_init(Shell::Bash);
+ assert!(!script.contains("${{"), "double braces in bash init script");
+ }
+
+ #[test]
+ fn fish_init_uses_source() {
+ let script = render_init(Shell::Fish);
+ assert!(script.contains("exec atuin pty-proxy"));
+ assert!(!script.contains("atuin init fish | source"));
+ }
+
+ #[test]
+ fn nu_init_uses_exec_and_tty_guard() {
+ let script = render_init(Shell::Nu);
+ assert!(script.contains("exec atuin pty-proxy"));
+ assert!(script.contains("ATUIN_PTY_PROXY_TMUX"));
+ assert!(script.contains("is-terminal --stdin"));
+ assert!(script.contains("is-terminal --stdout"));
+ assert!(script.contains("ATUIN_PTY_PROXY_ACTIVE"));
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/runtime.rs b/crates/client/src/atuin_pty_proxy/runtime.rs
new file mode 100644
index 00000000..69b3a075
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/runtime.rs
@@ -0,0 +1,184 @@
+use std::io::{Read, Write};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU16, Ordering};
+use std::sync::mpsc;
+
+use crossterm::terminal;
+use portable_pty::{CommandBuilder, PtySize, native_pty_system};
+
+use crate::atuin_pty_proxy::capture::CommandCaptureTracker;
+use crate::atuin_pty_proxy::debug::{Osc133DebugHighlighter, RESET};
+use crate::atuin_pty_proxy::pty_proxy::RuntimeOptions;
+use crate::atuin_pty_proxy::screen::{self, Msg};
+
+pub(crate) fn main(options: RuntimeOptions) {
+ if let Err(e) = run(options) {
+ drop(terminal::disable_raw_mode());
+ eprintln!("atuin pty-proxy: {e:#}");
+ std::process::exit(1);
+ }
+}
+
+fn run(options: RuntimeOptions) -> eyre::Result<()> {
+ let (cols, rows) = terminal::size()?;
+
+ let pty_system = native_pty_system();
+ let pair = pty_system
+ .openpty(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ let sock_path = screen::socket_path();
+ drop(std::fs::remove_file(&sock_path));
+
+ let mut cmd = CommandBuilder::new_default_prog();
+ cmd.cwd(std::env::current_dir()?);
+ cmd.env("ATUIN_PTY_PROXY_SOCKET", sock_path.as_os_str());
+ cmd.env("ATUIN_PTY_PROXY_ACTIVE", "1");
+
+ let mut child = pair
+ .slave
+ .spawn_command(cmd)
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ drop(pair.slave);
+
+ let mut pty_reader = pair
+ .master
+ .try_clone_reader()
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+ let mut pty_writer = pair
+ .master
+ .take_writer()
+ .map_err(|e| eyre::eyre!("{e:#}"))?;
+
+ let (msg_tx, msg_rx) = mpsc::sync_channel::<Msg>(64);
+ let current_cols = Arc::new(AtomicU16::new(cols.max(1)));
+
+ screen::spawn_parser_thread(rows, cols, msg_rx);
+ screen::spawn_socket_server(sock_path.clone(), msg_tx.clone());
+ spawn_resize_handler(pair.master, msg_tx.clone(), current_cols.clone())?;
+
+ terminal::enable_raw_mode()?;
+
+ let stdout_thread = std::thread::spawn(move || {
+ let mut stdout = std::io::stdout();
+ let mut highlighter = options.debug_osc133.then(Osc133DebugHighlighter::new);
+ let mut capture_tracker = options
+ .command_capture_sink
+ .as_ref()
+ .map(|_| CommandCaptureTracker::new(current_cols));
+ let mut buf = [0u8; 8192];
+
+ loop {
+ match pty_reader.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ if let (Some(tracker), Some(sink)) = (
+ capture_tracker.as_mut(),
+ options.command_capture_sink.as_ref(),
+ ) {
+ tracker.push(&buf[..n], sink);
+ }
+
+ if let Some(highlighter) = highlighter.as_mut() {
+ let rendered = highlighter.render(&buf[..n]);
+ drop(msg_tx.try_send(Msg::Data(rendered.clone())));
+
+ if stdout.write_all(&rendered).is_err() {
+ break;
+ }
+ } else {
+ drop(msg_tx.try_send(Msg::Data(buf[..n].to_vec())));
+
+ if stdout.write_all(&buf[..n]).is_err() {
+ break;
+ }
+ }
+ drop(stdout.flush());
+ }
+ }
+ }
+
+ if highlighter.is_some() {
+ drop(stdout.write_all(RESET));
+ drop(stdout.flush());
+ }
+ });
+
+ std::thread::spawn(move || {
+ let mut stdin = std::io::stdin();
+ let mut buf = [0u8; 8192];
+ loop {
+ match stdin.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ if pty_writer.write_all(&buf[..n]).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ });
+
+ let status = child.wait()?;
+ drop(stdout_thread.join());
+
+ drop(terminal::disable_raw_mode());
+ drop(std::fs::remove_file(&sock_path));
+
+ std::process::exit(process_exit_code(status.exit_code()));
+}
+
+fn spawn_resize_handler(
+ master: Box<dyn portable_pty::MasterPty + Send>,
+ resize_tx: mpsc::SyncSender<Msg>,
+ current_cols: Arc<AtomicU16>,
+) -> eyre::Result<()> {
+ use signal_hook::consts::SIGWINCH;
+ use signal_hook::iterator::Signals;
+
+ let mut signals = Signals::new([SIGWINCH])?;
+
+ std::thread::spawn(move || {
+ for _ in signals.forever() {
+ if let Ok((cols, rows)) = terminal::size() {
+ current_cols.store(cols.max(1), Ordering::Relaxed);
+ drop(master.resize(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ }));
+ drop(resize_tx.try_send(Msg::Resize { rows, cols }));
+ }
+ }
+ });
+
+ Ok(())
+}
+
+fn process_exit_code(code: u32) -> i32 {
+ i32::try_from(code).unwrap_or(1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::process_exit_code;
+
+ #[test]
+ fn process_exit_code_preserves_valid_values() {
+ assert_eq!(process_exit_code(0), 0);
+ assert_eq!(process_exit_code(127), 127);
+ assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX);
+ }
+
+ #[test]
+ fn process_exit_code_defaults_when_out_of_range() {
+ assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1);
+ }
+}
diff --git a/crates/client/src/atuin_pty_proxy/screen.rs b/crates/client/src/atuin_pty_proxy/screen.rs
new file mode 100644
index 00000000..58ebd2eb
--- /dev/null
+++ b/crates/client/src/atuin_pty_proxy/screen.rs
@@ -0,0 +1,96 @@
+use std::io::Write;
+use std::os::unix::net::UnixListener;
+use std::path::PathBuf;
+use std::sync::mpsc::{self, Receiver, SyncSender};
+
+pub(crate) enum Msg {
+ Data(Vec<u8>),
+ Resize { rows: u16, cols: u16 },
+ ScreenRequest(mpsc::Sender<Vec<u8>>),
+}
+
+pub(crate) fn socket_path() -> PathBuf {
+ let dir = std::env::temp_dir();
+ dir.join(format!("atuin-pty-proxy-{}.sock", std::process::id()))
+}
+
+pub(crate) fn spawn_parser_thread(rows: u16, cols: u16, msg_rx: Receiver<Msg>) {
+ std::thread::spawn(move || {
+ let mut parser = vt100::Parser::new(rows, cols, 0);
+
+ while let Ok(first) = msg_rx.recv() {
+ handle_parser_msg(&mut parser, first);
+
+ while let Ok(msg) = msg_rx.try_recv() {
+ handle_parser_msg(&mut parser, msg);
+ }
+ }
+ });
+}
+
+pub(crate) fn spawn_socket_server(sock_path: PathBuf, screen_tx: SyncSender<Msg>) {
+ std::thread::spawn(move || {
+ let listener = match UnixListener::bind(&sock_path) {
+ Ok(l) => l,
+ Err(e) => {
+ eprintln!("atuin pty-proxy: failed to bind socket: {e}");
+ return;
+ }
+ };
+
+ for stream in listener.incoming() {
+ let Ok(mut stream) = stream else { break };
+
+ let (reply_tx, reply_rx) = mpsc::channel();
+ if screen_tx.send(Msg::ScreenRequest(reply_tx)).is_err() {
+ break;
+ }
+ if let Ok(data) = reply_rx.recv() {
+ drop(stream.write_all(&data));
+ drop(stream.flush());
+ }
+ }
+ });
+}
+
+/// Wire format written to the Unix socket:
+///
+/// ```text
+/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
+/// [row_0_len: u32 BE][row_0_bytes...]
+/// [row_1_len: u32 BE][row_1_bytes...]
+/// ...
+/// ```
+///
+/// Each row's bytes come from `screen.rows_formatted(0, cols)` and contain
+/// pre-built ANSI escape sequences. The client can write them directly to
+/// stdout without needing its own vt100 parser.
+fn encode_screen(parser: &vt100::Parser) -> Vec<u8> {
+ let screen = parser.screen();
+ let (rows, cols) = screen.size();
+ let (cursor_row, cursor_col) = screen.cursor_position();
+
+ let mut buf: Vec<u8> = Vec::with_capacity(256 + (rows as usize * cols as usize));
+ buf.extend_from_slice(&rows.to_be_bytes());
+ buf.extend_from_slice(&cols.to_be_bytes());
+ buf.extend_from_slice(&cursor_row.to_be_bytes());
+ buf.extend_from_slice(&cursor_col.to_be_bytes());
+
+ for row_bytes in screen.rows_formatted(0, cols) {
+ let len = row_bytes.len() as u32;
+ buf.extend_from_slice(&len.to_be_bytes());
+ buf.extend_from_slice(&row_bytes);
+ }
+
+ buf
+}
+
+fn handle_parser_msg(parser: &mut vt100::Parser, msg: Msg) {
+ match msg {
+ Msg::Data(data) => parser.process(&data),
+ Msg::Resize { rows, cols } => parser.screen_mut().set_size(rows, cols),
+ Msg::ScreenRequest(reply_tx) => {
+ drop(reply_tx.send(encode_screen(parser)));
+ }
+ }
+}
diff --git a/crates/client/src/command/CONTRIBUTORS b/crates/client/src/command/CONTRIBUTORS
new file mode 120000
index 00000000..1ca4115a
--- /dev/null
+++ b/crates/client/src/command/CONTRIBUTORS
@@ -0,0 +1 @@
+../../../../CONTRIBUTORS \ No newline at end of file
diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs
new file mode 100644
index 00000000..6c0e70e8
--- /dev/null
+++ b/crates/client/src/command/client.rs
@@ -0,0 +1,327 @@
+use std::fs::{self, OpenOptions};
+use std::path::{Path, PathBuf};
+
+use clap::Subcommand;
+use eyre::{Result, WrapErr};
+use tracing_subscriber::util::SubscriberInitExt;
+
+use tracing_appender::rolling::{RollingFileAppender, Rotation};
+use tracing_subscriber::{
+ Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan,
+ prelude::__tracing_subscriber_SubscriberExt,
+};
+
+use crate::atuin_client::{
+ database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
+};
+
+fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
+ let cutoff = std::time::SystemTime::now()
+ - std::time::Duration::from_secs(retention_days * 24 * 60 * 60);
+
+ let Ok(entries) = fs::read_dir(log_dir) else {
+ return;
+ };
+
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+ continue;
+ };
+
+ // Match files like "search.log.2024-02-23" or "daemon.log.2024-02-23"
+ if !name.starts_with(prefix) || name == prefix {
+ continue;
+ }
+
+ if let Ok(metadata) = entry.metadata()
+ && let Ok(modified) = metadata.modified()
+ && modified < cutoff
+ {
+ drop(fs::remove_file(&path));
+ }
+ }
+}
+
+mod config;
+mod daemon;
+mod default_config;
+mod history;
+mod info;
+mod init;
+mod search;
+mod server;
+mod stats;
+mod store;
+mod sync;
+mod wrapped;
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Manipulate shell history
+ #[command(subcommand)]
+ History(history::Cmd),
+
+ /// Interactive history search
+ Search(search::Cmd),
+
+ #[command(subcommand)]
+ /// Request a sync or view sync status
+ Sync(sync::Cmd),
+
+ /// Manage the atuin server
+ #[command(subcommand)]
+ Server(server::Cmd),
+
+ /// Manage the atuin data store
+ #[command(subcommand)]
+ Store(store::Cmd),
+
+ /// Print Atuin's shell init script
+ #[command()]
+ Init(init::Cmd),
+
+ /// Information about dotfiles locations and ENV vars
+ #[command()]
+ Info,
+
+ /// Calculate statistics for your history
+ Stats(stats::Cmd),
+
+ #[command()]
+ /// Display a recap of your last year's history
+ Wrapped { year: Option<i32> },
+
+ /// *Experimental* Manage the background daemon
+ #[command()]
+ Daemon(daemon::Cmd),
+
+ /// Print the default atuin configuration (config.toml)
+ #[command()]
+ DefaultConfig,
+
+ #[command(subcommand)]
+ /// Manage your configuration
+ Config(config::Cmd),
+}
+
+impl Cmd {
+ pub(crate) fn run(self) -> Result<()> {
+ // Daemonize before creating the async runtime – fork() inside a live
+ // tokio runtime corrupts its internal state.
+ #[cfg(unix)]
+ if let Self::Daemon(ref cmd) = self
+ && cmd.should_daemonize()
+ {
+ daemon::daemonize_current_process()?;
+ }
+
+ let mut runtime = tokio::runtime::Builder::new_current_thread();
+
+ let runtime = runtime.enable_all().build().unwrap();
+
+ // Start the server before descending into the client-specific setup code.
+ // We simply cannot setup settings or a theme on the server, because the client-specific
+ // stuff will error out.
+ let res = if let Self::Server(server) = self {
+ runtime.block_on(server.run())
+ } else {
+ // For non-history commands, we want to initialize logging and the theme manager before
+ // doing anything else. History commands are performance-sensitive and run before and after
+ // every shell command, so we want to skip any unnecessary initialization for them.
+ let settings = Settings::new().wrap_err("could not load client settings")?;
+
+ runtime.block_on(self.run_inner(settings))
+ };
+
+ runtime.shutdown_timeout(std::time::Duration::from_millis(50));
+
+ res
+ }
+
+ #[expect(clippy::too_many_lines)]
+ async fn run_inner(self, mut settings: Settings) -> Result<()> {
+ // ATUIN_LOG env var overrides config file level settings
+ let env_log_set = std::env::var("ATUIN_LOG").is_ok();
+
+ // Base filter from env var (or empty if not set)
+ let base_filter =
+ EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?);
+
+ let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive());
+ // Use file-based logging for interactive search (TUI mode)
+ let use_search_logging = is_interactive_search && settings.logs.search_enabled();
+
+ // Use file-based logging for daemon
+ let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled();
+
+ // Check if daemon should also log to console
+ let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs());
+
+ // Set up span timing JSON logs if ATUIN_SPAN is set
+ let span_path = std::env::var("ATUIN_SPAN").ok().map(|p| {
+ if p.is_empty() {
+ "atuin-spans.json".to_string()
+ } else {
+ p
+ }
+ });
+
+ // Helper to create span timing layer
+ macro_rules! make_span_layer {
+ ($path:expr) => {{
+ let span_file = OpenOptions::new()
+ .create(true)
+ .truncate(true)
+ .write(true)
+ .open($path)?;
+ Some(
+ fmt::layer()
+ .json()
+ .with_writer(span_file)
+ .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
+ .with_filter(LevelFilter::TRACE),
+ )
+ }};
+ }
+
+ // Build the subscriber with all configured layers
+ if use_search_logging {
+ let search_filename = settings.logs.search.file.clone();
+ let log_dir = PathBuf::from(&settings.logs.dir);
+ fs::create_dir_all(&log_dir)?;
+
+ // Clean up old log files
+ cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention());
+
+ let file_appender =
+ RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename);
+
+ // Use config level unless ATUIN_LOG is set
+ let filter = if env_log_set {
+ base_filter
+ } else {
+ EnvFilter::default()
+ .add_directive(settings.logs.search_level().as_directive().parse()?)
+ .add_directive("sqlx_sqlite::regexp=off".parse()?)
+ };
+
+ let base = tracing_subscriber::registry().with(
+ fmt::layer()
+ .with_writer(file_appender)
+ .with_ansi(false)
+ .with_filter(filter),
+ );
+
+ match &span_path {
+ Some(sp) => {
+ base.with(make_span_layer!(sp)).init();
+ }
+ None => {
+ base.init();
+ }
+ }
+ } else if use_daemon_logging {
+ let daemon_filename = settings.logs.daemon.file.clone();
+ let log_dir = PathBuf::from(&settings.logs.dir);
+ fs::create_dir_all(&log_dir)?;
+
+ // Clean up old log files
+ cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention());
+
+ let file_appender =
+ RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename);
+
+ // Use config level unless ATUIN_LOG is set
+ let file_filter = if env_log_set {
+ base_filter
+ } else {
+ EnvFilter::default()
+ .add_directive(settings.logs.daemon_level().as_directive().parse()?)
+ .add_directive("sqlx_sqlite::regexp=off".parse()?)
+ };
+
+ let file_layer = fmt::layer()
+ .with_writer(file_appender)
+ .with_ansi(false)
+ .with_filter(file_filter);
+
+ // Optionally add console layer for --show-logs
+ if daemon_show_logs {
+ let console_filter = EnvFilter::from_env("ATUIN_LOG")
+ .add_directive("sqlx_sqlite::regexp=off".parse()?);
+
+ let console_layer = fmt::layer().with_filter(console_filter);
+
+ let base = tracing_subscriber::registry()
+ .with(file_layer)
+ .with(console_layer);
+
+ match &span_path {
+ Some(sp) => {
+ base.with(make_span_layer!(sp)).init();
+ }
+ None => {
+ base.init();
+ }
+ }
+ } else {
+ let base = tracing_subscriber::registry().with(file_layer);
+
+ match &span_path {
+ Some(sp) => {
+ base.with(make_span_layer!(sp)).init();
+ }
+ None => {
+ base.init();
+ }
+ }
+ }
+ }
+
+ tracing::trace!(command = ?self, "client command");
+
+ // Skip initializing any databases for history
+ // This is a pretty hot path, as it runs before and after every single command the user
+ // runs
+ match self {
+ Self::History(history) => return history.run(&settings).await,
+ Self::Init(init) => {
+ init.run(&settings);
+ return Ok(());
+ }
+ Self::Config(config) => return config.run(&settings).await,
+ _ => {}
+ }
+
+ let db_path = PathBuf::from(settings.db_path.as_str());
+ let record_store_path = PathBuf::from(settings.record_store_path.as_str());
+
+ let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
+ let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
+
+ match self {
+ Self::Stats(stats) => stats.run(&db, &settings).await,
+ Self::Search(search) => search.run(db, &mut settings, sqlite_store).await,
+ Self::Wrapped { year } => wrapped::run(year, &db, &settings).await,
+
+ Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,
+
+ Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
+
+ Self::Info => info::run(&settings),
+
+ Self::DefaultConfig => {
+ default_config::run();
+ Ok(())
+ }
+
+ Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await,
+
+ Self::History(_) | Self::Init(_) | Self::Config(_) | Self::Server(_) => {
+ unreachable!()
+ }
+ }
+ }
+}
diff --git a/crates/client/src/command/client/config.rs b/crates/client/src/command/client/config.rs
new file mode 100644
index 00000000..73d1c35e
--- /dev/null
+++ b/crates/client/src/command/client/config.rs
@@ -0,0 +1,352 @@
+use crate::atuin_client::settings::Settings;
+use clap::{Args, Subcommand, ValueEnum};
+use eyre::Result;
+use toml_edit::{Document, DocumentMut, Item, Table, TableLike, Value};
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Get a configuration value from your config.toml file
+ /// or after defaults and overrides are applied
+ #[command()]
+ Get(GetCmd),
+
+ /// Set a configuration value in your config.toml file
+ #[command()]
+ Set(SetCmd),
+
+ /// Print all configuration values from your config.toml file
+ /// in TOML format
+ ///
+ /// If a key is provided, only print the value of that key and all its children
+ #[command()]
+ Print(PrintCmd),
+}
+
+impl Cmd {
+ pub(crate) async fn run(self, settings: &Settings) -> Result<()> {
+ match self {
+ Self::Get(get) => get.run(settings).await,
+ Self::Set(set) => set.run(settings).await,
+ Self::Print(print) => print.run(settings).await,
+ }
+ }
+}
+
+/// Get a configuration value from your config.toml file,
+/// or optionally the effective value after defaults and overrides are applied.
+#[derive(Args, Debug)]
+pub(crate) struct GetCmd {
+ /// The configuration key to get
+ pub(crate) key: String,
+
+ /// Print the value after defaults and overrides are applied
+ #[arg(long, short)]
+ pub(crate) resolved: bool,
+
+ /// Print both the config file value and the resolved value
+ #[arg(long, short)]
+ pub(crate) verbose: bool,
+}
+
+impl GetCmd {
+ pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> {
+ let key = self.key.trim();
+ if key.is_empty() || key.contains(char::is_whitespace) {
+ eyre::bail!("Config key must be non-empty and must not contain whitespace");
+ }
+
+ if self.verbose {
+ println!("Config file:");
+ self.print_current_value(key, " ").await?;
+ println!("\nResolved:");
+ Self::print_effective_value(key, " ");
+ return Ok(());
+ }
+
+ if self.resolved {
+ Self::print_effective_value(key, "");
+ } else {
+ self.print_current_value(key, "").await?;
+ }
+
+ Ok(())
+ }
+
+ async fn print_current_value(&self, key: &str, prefix: &str) -> Result<()> {
+ let config_file = Settings::get_config_path()?;
+ let config_str = tokio::fs::read_to_string(&config_file).await?;
+ let doc = config_str.parse::<Document<_>>()?;
+
+ let current = get_deep_key(&doc, key);
+
+ match current {
+ Some(item) if item.is_table() || item.is_inline_table() => {
+ let table = item
+ .as_table_like()
+ .expect("is_table()/is_inline_table() but no table");
+ println!("{prefix}[{key}]");
+ dump_table(table, prefix, &mut vec![key.to_string()])?;
+ }
+ Some(item) => {
+ let val = item.to_string();
+ let val = val.trim().trim_matches('"');
+ println!("{prefix}{val}");
+ }
+ None => {
+ println!("{prefix}(not set in config file)");
+ }
+ }
+
+ Ok(())
+ }
+
+ fn print_effective_value(key: &str, prefix: &str) {
+ match Settings::get_config_value(key) {
+ Ok(value) => {
+ for line in value.lines() {
+ println!("{prefix}{line}");
+ }
+ }
+ Err(_) => {
+ println!("{prefix}(unknown key)");
+ }
+ }
+ }
+}
+
+#[derive(Args, Debug)]
+pub(crate) struct SetCmd {
+ /// The configuration key to set
+ pub(crate) key: String,
+
+ /// The value to set
+ pub(crate) value: String,
+
+ /// Store value as an explicit type
+ #[arg(long = "type", short, value_enum, default_value_t = ValueType::Auto, value_name = "TYPE")]
+ pub(crate) the_type: ValueType,
+}
+
+#[derive(ValueEnum, Debug, Clone, PartialEq, Eq)]
+pub(crate) enum ValueType {
+ /// Automatically determine the type of the value
+ Auto,
+ /// Store value as a string
+ String,
+ /// Store value as a boolean
+ Boolean,
+ /// Store value as an integer
+ Integer,
+ /// Store the value as a float
+ Float,
+}
+
+impl SetCmd {
+ pub(crate) async fn run(self, _settings: &Settings) -> Result<()> {
+ let key = self.key.trim();
+ if key.is_empty() || key.contains(char::is_whitespace) {
+ eyre::bail!("Config key must be non-empty and must not contain whitespace");
+ }
+
+ let config_file = Settings::get_config_path()?;
+ let config_str = tokio::fs::read_to_string(&config_file).await?;
+ let mut doc: DocumentMut = config_str.parse()?;
+
+ // When using auto type detection, try to match the existing value's type
+ // so we don't accidentally change e.g. "300" (string) to 300 (integer)
+ let existing_type = detect_existing_type(&doc, key);
+ let value = self.parse_value(existing_type.as_ref())?;
+ set_deep_key(&mut doc, key, value)?;
+
+ tokio::fs::write(&config_file, doc.to_string()).await?;
+
+ Ok(())
+ }
+
+ fn parse_value(&self, existing_type: Option<&ValueType>) -> Result<Value> {
+ let raw = &self.value;
+
+ // Explicit --type takes priority, then existing value type, then auto-detect
+ let effective_type = if self.the_type != ValueType::Auto {
+ &self.the_type
+ } else if let Some(existing) = existing_type {
+ existing
+ } else {
+ &ValueType::Auto
+ };
+
+ match effective_type {
+ ValueType::String => Ok(Value::from(raw.as_str())),
+ ValueType::Boolean => {
+ let b: bool = raw
+ .parse()
+ .map_err(|_| eyre::eyre!("invalid boolean value: {raw}"))?;
+ Ok(Value::from(b))
+ }
+ ValueType::Integer => {
+ let i: i64 = raw
+ .parse()
+ .map_err(|_| eyre::eyre!("invalid integer value: {raw}"))?;
+ Ok(Value::from(i))
+ }
+ ValueType::Float => {
+ let f: f64 = raw
+ .parse()
+ .map_err(|_| eyre::eyre!("invalid float value: {raw}"))?;
+ Ok(Value::from(f))
+ }
+ ValueType::Auto => {
+ if raw == "true" || raw == "false" {
+ return Ok(Value::from(raw == "true"));
+ }
+ if let Ok(i) = raw.parse::<i64>() {
+ return Ok(Value::from(i));
+ }
+ if let Ok(f) = raw.parse::<f64>() {
+ return Ok(Value::from(f));
+ }
+ Ok(Value::from(raw.as_str()))
+ }
+ }
+ }
+}
+
+#[derive(Args, Debug)]
+pub(crate) struct PrintCmd {
+ /// Print the value of a specific key and all its children
+ pub(crate) key: Option<String>,
+}
+
+impl PrintCmd {
+ pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> {
+ let config_file = Settings::get_config_path()?;
+ let config_str = tokio::fs::read_to_string(&config_file).await?;
+ let doc = config_str.parse::<Document<_>>()?;
+
+ if let Some(key) = &self.key {
+ let current = get_deep_key(&doc, key);
+
+ if let Some(current) = current {
+ if current.is_table() || current.is_inline_table() {
+ println!("[{key}]");
+ dump_table(
+ current
+ .as_table_like()
+ .expect("is_table()/is_inline_table() but no table"),
+ "",
+ &mut vec![key.clone()],
+ )?;
+ } else {
+ println!("{}", current.to_string().trim().trim_matches('"'));
+ }
+ } else {
+ println!("key not found");
+ }
+ } else {
+ dump_table(doc.as_table(), "", &mut Vec::new())?;
+ }
+
+ Ok(())
+ }
+}
+
+fn dump_table(table: &dyn TableLike, prefix: &str, stack: &mut Vec<String>) -> Result<()> {
+ for (key, value) in table.iter() {
+ if value.is_table() || value.is_inline_table() {
+ stack.push(key.to_string());
+
+ let table = value
+ .as_table_like()
+ .expect("is_table()/is_inline_table() but no table");
+
+ println!("\n{}[{}]", prefix, stack.join("."));
+
+ dump_table(table, prefix, stack)?;
+
+ stack.pop();
+ } else {
+ println!("{prefix}{key} = {value}");
+ }
+ }
+
+ Ok(())
+}
+
+fn get_deep_key<'doc>(doc: &'doc Document<String>, key: &str) -> Option<&'doc Item> {
+ let parts = key.split('.');
+ let mut current: Option<&Item> = Some(doc.as_item());
+
+ for part in parts {
+ current = current
+ .and_then(|item| item.as_table_like())
+ .and_then(|table| table.get(part));
+ }
+
+ current
+}
+
+/// Detect the TOML type of an existing key in the document, so `set` with auto
+/// type detection preserves the original type rather than guessing from the value string.
+fn detect_existing_type(doc: &DocumentMut, key: &str) -> Option<ValueType> {
+ let parts: Vec<&str> = key.split('.').collect();
+ let mut current: &dyn TableLike = doc.as_table();
+
+ for &part in &parts[..parts.len().saturating_sub(1)] {
+ current = current.get(part)?.as_table_like()?;
+ }
+
+ let last = parts.last()?;
+ let v = current.get(last)?.as_value()?;
+
+ if v.is_str() {
+ Some(ValueType::String)
+ } else if v.is_bool() {
+ Some(ValueType::Boolean)
+ } else if v.is_integer() {
+ Some(ValueType::Integer)
+ } else if v.is_float() {
+ Some(ValueType::Float)
+ } else {
+ None
+ }
+}
+
+fn set_deep_key(doc: &mut DocumentMut, key: &str, value: Value) -> Result<()> {
+ let parts: Vec<&str> = key.split('.').collect();
+
+ if parts.is_empty() {
+ eyre::bail!("empty config key");
+ }
+
+ let mut current: &mut dyn TableLike = doc.as_table_mut();
+
+ // Navigate/create intermediate tables
+ for &part in &parts[..parts.len() - 1] {
+ if !current.contains_key(part) {
+ current.insert(part, Item::Table(Table::new()));
+ }
+ current = current
+ .get_mut(part)
+ .expect("just inserted or already exists")
+ .as_table_like_mut()
+ .ok_or_else(|| eyre::eyre!("'{}' exists but is not a table", part))?;
+ }
+
+ let last = *parts.last().unwrap();
+
+ // Don't silently overwrite a table with a scalar value
+ if let Some(existing) = current.get(last)
+ && (existing.is_table() || existing.is_inline_table())
+ {
+ eyre::bail!(
+ "'{}' is a table; use a dotted key like '{}.key' to set a value within it",
+ key,
+ key
+ );
+ }
+
+ current.insert(last, Item::Value(value));
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs
new file mode 100644
index 00000000..39aa1b1e
--- /dev/null
+++ b/crates/client/src/command/client/daemon.rs
@@ -0,0 +1,583 @@
+use std::fs::{self, File, OpenOptions};
+use std::io::{ErrorKind, Write};
+#[cfg(unix)]
+use std::os::unix::net::UnixStream as StdUnixStream;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::time::{Duration, Instant};
+
+use crate::atuin_client::{
+ database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings,
+};
+use crate::atuin_daemon::DaemonEvent;
+use crate::atuin_daemon::client::{
+ ControlClient, DaemonClientErrorKind, HistoryClient, classify_error,
+};
+use clap::Subcommand;
+#[cfg(unix)]
+use daemonize::Daemonize;
+use eyre::{Result, WrapErr, bail, eyre};
+use fs4::fs_std::FileExt;
+use tokio::time::sleep;
+
+#[derive(clap::Args, Debug)]
+pub(crate) struct Cmd {
+ #[command(subcommand)]
+ subcmd: SubCmd,
+}
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum SubCmd {
+ /// Start the daemon server
+ Start {
+ #[arg(long, hide = true)]
+ daemonize: bool,
+
+ /// Also write daemon logs to the console (useful for debugging)
+ #[arg(long)]
+ show_logs: bool,
+
+ /// Force start: kill existing daemon process and reset the socket
+ #[arg(long)]
+ force: bool,
+ },
+
+ /// Show the daemon's current status
+ Status,
+
+ /// Stop the daemon gracefully
+ Stop,
+
+ /// Restart the daemon (stop, then start in background)
+ Restart,
+}
+
+impl Cmd {
+ /// Returns `true` when the process should daemonize before creating the
+ /// async runtime or opening any database connections.
+ #[cfg(unix)]
+ pub(crate) fn should_daemonize(&self) -> bool {
+ match &self.subcmd {
+ SubCmd::Start { daemonize, .. } => *daemonize,
+ _ => false,
+ }
+ }
+
+ /// Returns `true` when logs should also be written to the console.
+ pub(crate) fn show_logs(&self) -> bool {
+ match &self.subcmd {
+ SubCmd::Start { show_logs, .. } => *show_logs,
+ _ => false,
+ }
+ }
+
+ pub(crate) async fn run(
+ self,
+ settings: Settings,
+ store: SqliteStore,
+ history_db: ClientSqlite,
+ ) -> Result<()> {
+ match self.subcmd {
+ SubCmd::Start { force, .. } => run(settings, store, history_db, force).await,
+ SubCmd::Status => status_cmd(&settings).await,
+ SubCmd::Stop => stop_cmd(&settings).await,
+ SubCmd::Restart => restart_cmd(&settings).await,
+ }
+ }
+}
+
+const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
+const DAEMON_PROTOCOL_VERSION: u32 = 1;
+const STARTUP_POLL: Duration = Duration::from_millis(40);
+const LOCK_POLL: Duration = Duration::from_millis(20);
+const LEGACY_DAEMON_RESTART_MESSAGE: &str = "legacy daemon detected; restart daemon manually";
+
+struct PidfileGuard {
+ file: File,
+}
+
+impl PidfileGuard {
+ fn acquire(path: &Path) -> Result<Self> {
+ let mut file = open_lock_file(path)?;
+
+ if !file.try_lock_exclusive()? {
+ bail!(
+ "daemon already running (pidfile lock busy at {})",
+ path.display()
+ );
+ }
+
+ file.set_len(0)
+ .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?;
+ writeln!(file, "{}", std::process::id())
+ .and_then(|()| writeln!(file, "{DAEMON_VERSION}"))
+ .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?;
+
+ Ok(Self { file })
+ }
+}
+
+impl Drop for PidfileGuard {
+ fn drop(&mut self) {
+ drop(self.file.unlock());
+ }
+}
+
+enum Probe {
+ Ready(HistoryClient),
+ NeedsRestart(String),
+ Unreachable(eyre::Report),
+}
+
+fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
+ version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION
+}
+
+fn daemon_mismatch_message(version: &str, protocol: u32) -> String {
+ if protocol == DAEMON_PROTOCOL_VERSION {
+ format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}")
+ } else {
+ format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}")
+ }
+}
+
+fn is_legacy_daemon_error(err: &eyre::Report) -> bool {
+ matches!(classify_error(err), DaemonClientErrorKind::Unimplemented)
+}
+
+fn open_lock_file(path: &Path) -> Result<File> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?;
+ }
+
+ OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(path)
+ .wrap_err_with(|| format!("could not open lock file {}", path.display()))
+}
+
+async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {
+ let file = open_lock_file(path)?;
+ let start = Instant::now();
+
+ loop {
+ match file.try_lock_exclusive() {
+ Ok(true) => return Ok(file),
+ Ok(false) => {
+ if start.elapsed() >= timeout {
+ bail!("timed out waiting for lock at {}", path.display());
+ }
+
+ sleep(LOCK_POLL).await;
+ }
+ Err(err) => {
+ return Err(eyre!("could not lock {}: {err}", path.display()));
+ }
+ }
+ }
+}
+
+async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {
+ let file = wait_for_lock(path, timeout).await?;
+ file.unlock()
+ .wrap_err_with(|| format!("failed to unlock {}", path.display()))?;
+ Ok(())
+}
+
+async fn connect_client(settings: &Settings) -> Result<HistoryClient> {
+ HistoryClient::new(
+ #[cfg(unix)]
+ settings.daemon.socket_path.clone(),
+ )
+ .await
+}
+
+async fn probe(settings: &Settings) -> Probe {
+ let mut client = match connect_client(settings).await {
+ Ok(client) => client,
+ Err(err) => return Probe::Unreachable(err),
+ };
+
+ match client.status().await {
+ Ok(status) => {
+ if daemon_matches_expected(&status.version, status.protocol) {
+ Probe::Ready(client)
+ } else {
+ Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))
+ }
+ }
+ Err(err) => Probe::Unreachable(err),
+ }
+}
+
+async fn request_shutdown(settings: &Settings) {
+ if let Ok(mut client) = connect_client(settings).await {
+ drop(client.shutdown().await);
+ }
+}
+
+fn spawn_daemon_process() -> Result<()> {
+ let exe = std::env::current_exe().wrap_err("could not locate atuin executable")?;
+
+ let mut cmd = Command::new(exe);
+ cmd.arg("daemon")
+ .arg("start")
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ #[cfg(unix)]
+ cmd.arg("--daemonize");
+
+ cmd.spawn().wrap_err("failed to spawn daemon process")?;
+
+ Ok(())
+}
+
+fn startup_timeout(settings: &Settings) -> Duration {
+ Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0)
+}
+
+#[cfg(unix)]
+fn remove_stale_socket_if_present(settings: &Settings) -> Result<()> {
+ if settings.daemon.systemd_socket {
+ return Ok(());
+ }
+
+ let socket_path = Path::new(&settings.daemon.socket_path);
+ if !socket_path.exists() {
+ return Ok(());
+ }
+
+ match StdUnixStream::connect(socket_path) {
+ Ok(stream) => {
+ drop(stream);
+ Ok(())
+ }
+ Err(err) if err.kind() == ErrorKind::ConnectionRefused => {
+ fs::remove_file(socket_path).wrap_err_with(|| {
+ format!(
+ "failed to remove stale daemon socket {}",
+ socket_path.display()
+ )
+ })?;
+ Ok(())
+ }
+ Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
+ Err(_) => Ok(()),
+ }
+}
+
+async fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result<HistoryClient> {
+ let start = Instant::now();
+ let mut last_error = eyre!("daemon did not become ready");
+
+ loop {
+ match probe(settings).await {
+ Probe::Ready(client) => return Ok(client),
+ Probe::NeedsRestart(reason) => {
+ last_error = eyre!(reason);
+ }
+ Probe::Unreachable(err) => {
+ if is_legacy_daemon_error(&err) {
+ return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE));
+ }
+ last_error = err;
+ }
+ }
+
+ if start.elapsed() >= timeout {
+ return Err(last_error.wrap_err(format!(
+ "timed out waiting for daemon startup after {}ms",
+ timeout.as_millis()
+ )));
+ }
+
+ sleep(STARTUP_POLL).await;
+ }
+}
+
+pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> {
+ match async {
+ connect_client(settings)
+ .await?
+ .start_history(history.clone())
+ .await
+ }
+ .await
+ {
+ Ok(resp) => {
+ if daemon_matches_expected(&resp.version, resp.protocol) {
+ return Ok(resp.id);
+ }
+
+ Err(eyre!(
+ "{}. Restart the daemon manually",
+ daemon_mismatch_message(&resp.version, resp.protocol)
+ ))
+ }
+ Err(err) => Err(err),
+ }
+}
+
+pub(crate) async fn end_history(
+ settings: &Settings,
+ id: String,
+ duration: u64,
+ exit: i64,
+) -> Result<()> {
+ match async {
+ connect_client(settings)
+ .await?
+ .end_history(id.clone(), duration, exit)
+ .await
+ }
+ .await
+ {
+ Ok(resp) => {
+ if daemon_matches_expected(&resp.version, resp.protocol) {
+ return Ok(());
+ }
+
+ Err(eyre!(
+ "{}. Restart the daemon manually",
+ daemon_mismatch_message(&resp.version, resp.protocol)
+ ))
+ }
+ Err(err) => Err(err),
+ }
+}
+
+/// Emit a daemon event.
+pub(crate) async fn emit_event(settings: &Settings, event: DaemonEvent) {
+ // Try to connect and send
+ match ControlClient::from_settings(settings).await {
+ Ok(mut client) => {
+ if let Err(e) = client.send_event(event).await {
+ tracing::debug!(?e, "failed to send event to daemon");
+ }
+ }
+ Err(e) => {
+ tracing::debug!(?e, "daemon not available, skipping event emission");
+ }
+ }
+}
+
+pub(crate) async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
+ match probe(settings).await {
+ Probe::Ready(client) => Ok(client),
+ Probe::NeedsRestart(reason) => {
+ bail!("{reason}. Restart the daemon manually");
+ }
+ Probe::Unreachable(err) if is_legacy_daemon_error(&err) => {
+ Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE))
+ }
+ Probe::Unreachable(err) => Err(err),
+ }
+}
+
+async fn status_cmd(settings: &Settings) -> Result<()> {
+ match probe(settings).await {
+ Probe::Ready(mut client) => {
+ let status = client.status().await?;
+ println!("Daemon running");
+ println!(" PID: {}", status.pid);
+ println!(" Version: {}", status.version);
+ println!(" Protocol: {}", status.protocol);
+ println!(" Healthy: {}", status.healthy);
+ #[cfg(unix)]
+ println!(" Socket: {}", settings.daemon.socket_path);
+ }
+ Probe::NeedsRestart(reason) => {
+ println!("Daemon running (needs restart)");
+ println!(" Reason: {reason}");
+ }
+ Probe::Unreachable(_) => {
+ println!("Daemon is not running");
+ }
+ }
+
+ Ok(())
+}
+
+async fn stop_cmd(settings: &Settings) -> Result<()> {
+ let Ok(mut client) = connect_client(settings).await else {
+ println!("Daemon is not running");
+ return Ok(());
+ };
+
+ match client.shutdown().await {
+ Ok(true) => {
+ println!("Shutdown requested");
+
+ let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
+ let timeout = Duration::from_secs(5);
+ match wait_for_pidfile_available(&pidfile_path, timeout).await {
+ Ok(()) => println!("Daemon stopped"),
+ Err(_) => println!("Daemon may still be shutting down"),
+ }
+
+ Ok(())
+ }
+ Ok(false) => bail!("Daemon rejected shutdown request"),
+ Err(err) => Err(err.wrap_err("Failed to send shutdown request")),
+ }
+}
+
+async fn restart_cmd(settings: &Settings) -> Result<()> {
+ // Stop if running
+ match probe(settings).await {
+ Probe::Ready(_) | Probe::NeedsRestart(_) => {
+ request_shutdown(settings).await;
+ println!("Stopping daemon...");
+
+ let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
+ let timeout = Duration::from_secs(5);
+ wait_for_pidfile_available(&pidfile_path, timeout)
+ .await
+ .wrap_err("Timed out waiting for old daemon to stop")?;
+ }
+ Probe::Unreachable(_) => {
+ println!("No daemon running");
+ }
+ }
+
+ #[cfg(unix)]
+ remove_stale_socket_if_present(settings)?;
+
+ spawn_daemon_process()?;
+ println!("Starting daemon...");
+
+ let timeout = startup_timeout(settings);
+ let status = wait_until_ready(settings, timeout).await?.status().await?;
+
+ println!("Daemon restarted");
+ println!(" PID: {}", status.pid);
+ println!(" Version: {}", status.version);
+
+ Ok(())
+}
+
+/// Daemonize the current process. Must be called before creating the tokio
+/// runtime or opening database connections, since `fork()` inside an async
+/// runtime corrupts its internal state.
+#[cfg(unix)]
+pub(crate) fn daemonize_current_process() -> Result<()> {
+ let cwd =
+ std::env::current_dir().wrap_err("could not determine current directory for daemon")?;
+
+ Daemonize::new()
+ .working_directory(cwd)
+ .start()
+ .wrap_err("failed to daemonize process")?;
+
+ Ok(())
+}
+
+async fn run(
+ settings: Settings,
+ store: SqliteStore,
+ history_db: ClientSqlite,
+ force: bool,
+) -> Result<()> {
+ if force {
+ force_cleanup(&settings);
+ }
+
+ let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
+ let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;
+
+ crate::atuin_daemon::boot(settings, store, history_db).await?;
+
+ Ok(())
+}
+
+/// Force cleanup: kill existing daemon process and remove socket.
+fn force_cleanup(settings: &Settings) {
+ let pidfile_path = Path::new(&settings.daemon.pidfile_path);
+
+ // Read and kill the existing process if pidfile exists
+ if pidfile_path.exists() {
+ if let Ok(contents) = fs::read_to_string(pidfile_path)
+ && let Some(pid_str) = contents.lines().next()
+ && let Ok(pid) = pid_str.parse::<u32>()
+ {
+ kill_process(pid);
+ // Give it a moment to release resources
+ std::thread::sleep(Duration::from_millis(100));
+ }
+
+ // Remove the pidfile
+ if let Err(e) = fs::remove_file(pidfile_path)
+ && e.kind() != ErrorKind::NotFound
+ {
+ tracing::warn!("failed to remove pidfile: {e}");
+ }
+ }
+
+ // Remove the socket file
+ #[cfg(unix)]
+ {
+ let socket_path = Path::new(&settings.daemon.socket_path);
+ if socket_path.exists()
+ && let Err(e) = fs::remove_file(socket_path)
+ && e.kind() != ErrorKind::NotFound
+ {
+ tracing::warn!("failed to remove socket: {e}");
+ }
+ }
+}
+
+/// Kill a process by PID.
+#[cfg(unix)]
+fn kill_process(pid: u32) {
+ // Use kill command to send SIGTERM for graceful shutdown
+ drop(
+ Command::new("kill")
+ .args(["-TERM", &pid.to_string()])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status(),
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, daemon_matches_expected, daemon_mismatch_message,
+ };
+
+ #[test]
+ fn test_version_matches() {
+ assert!(daemon_matches_expected(
+ DAEMON_VERSION,
+ DAEMON_PROTOCOL_VERSION
+ ));
+ }
+
+ #[test]
+ fn test_version_mismatch() {
+ assert!(!daemon_matches_expected("0.0.0", DAEMON_PROTOCOL_VERSION));
+ assert!(!daemon_matches_expected(DAEMON_VERSION, 999));
+ assert!(!daemon_matches_expected("0.0.0", 999));
+ }
+
+ #[test]
+ fn test_mismatch_message_version() {
+ let msg = daemon_mismatch_message("0.0.0", DAEMON_PROTOCOL_VERSION);
+ assert!(msg.contains("out of date"), "got: {msg}");
+ assert!(msg.contains("0.0.0"));
+ assert!(msg.contains(DAEMON_VERSION));
+ }
+
+ #[test]
+ fn test_mismatch_message_protocol() {
+ let msg = daemon_mismatch_message(DAEMON_VERSION, 999);
+ assert!(msg.contains("protocol mismatch"), "got: {msg}");
+ }
+}
diff --git a/crates/client/src/command/client/default_config.rs b/crates/client/src/command/client/default_config.rs
new file mode 100644
index 00000000..4b03c909
--- /dev/null
+++ b/crates/client/src/command/client/default_config.rs
@@ -0,0 +1,4 @@
+pub(crate) fn run() {
+ // TODO(@bpeetz): Re-add the default settings option back (Settings::example_config()) <2026-06-11>
+ println!("TODO");
+}
diff --git a/crates/client/src/command/client/history.rs b/crates/client/src/command/client/history.rs
new file mode 100644
index 00000000..9f383a08
--- /dev/null
+++ b/crates/client/src/command/client/history.rs
@@ -0,0 +1,1136 @@
+use std::{
+ fmt::{self, Display},
+ io::{self, IsTerminal, Write},
+ path::PathBuf,
+ time::Duration,
+};
+
+use crate::{
+ atuin_common::utils::{self, Escapable as _},
+ command::client::daemon,
+};
+use clap::Subcommand;
+use eyre::{Context, Result, bail};
+use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
+
+use colored::Colorize;
+use serde::Serialize;
+
+use crate::atuin_daemon::generated::history::{HistoryEventKind, TailHistoryReply};
+
+use crate::atuin_client::{
+ database::{ClientSqlite, current_context},
+ encryption,
+ history::{History, store::HistoryStore},
+ record::sqlite_store::SqliteStore,
+ settings::{
+ FilterMode::{Directory, Global, Session},
+ Settings, Timezone,
+ },
+};
+
+use log::debug;
+use time::{OffsetDateTime, macros::format_description};
+
+use super::search::format_duration_into;
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Begins a new command in the history
+ Start {
+ /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable,
+ /// which does not need escaping and is more compatible between OS and shells
+ #[arg(long = "command-from-env", hide = true)]
+ cmd_env: bool,
+
+ /// Author of this command, eg `ellie`, `claude`, or `copilot`
+ #[arg(long)]
+ author: Option<String>,
+
+ /// Optional intent/rationale for running this command
+ #[arg(long)]
+ intent: Option<String>,
+
+ command: Vec<String>,
+ },
+
+ /// Finishes a new command in the history (adds time, exit code)
+ End {
+ id: String,
+
+ #[arg(long, short)]
+ exit: i64,
+
+ #[arg(long, short)]
+ duration: Option<u64>,
+ },
+
+ /// Stream history events from the daemon as they are received
+ Tail,
+
+ /// List all items in history
+ List {
+ #[arg(long, short)]
+ cwd: bool,
+
+ #[arg(long, short)]
+ session: bool,
+
+ #[arg(long)]
+ human: bool,
+
+ /// Show only the text of the command
+ #[arg(long)]
+ cmd_only: bool,
+
+ /// Terminate the output with a null, for better multiline support
+ #[arg(long)]
+ print0: bool,
+
+ #[arg(long, short, default_value = "true")]
+ // accept no value
+ #[arg(num_args(0..=1), default_missing_value("true"))]
+ // accept a value
+ #[arg(action = clap::ArgAction::Set)]
+ reverse: bool,
+
+ /// Display the command time in another timezone other than the configured default.
+ ///
+ /// This option takes one of the following kinds of values:
+ /// - the special value "local" (or "l") which refers to the system time zone
+ /// - an offset from UTC (e.g. "+9", "-2:30")
+ #[arg(long, visible_alias = "tz")]
+ timezone: Option<Timezone>,
+
+ /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid}
+ /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
+ #[arg(long, short)]
+ format: Option<String>,
+ },
+
+ /// Get the last command ran
+ Last {
+ #[arg(long)]
+ human: bool,
+
+ /// Show only the text of the command
+ #[arg(long)]
+ cmd_only: bool,
+
+ /// Display the command time in another timezone other than the configured default.
+ ///
+ /// This option takes one of the following kinds of values:
+ /// - the special value "local" (or "l") which refers to the system time zone
+ /// - an offset from UTC (e.g. "+9", "-2:30")
+ #[arg(long, visible_alias = "tz")]
+ timezone: Option<Timezone>,
+
+ /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}.
+ /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
+ #[arg(long, short)]
+ format: Option<String>,
+ },
+
+ InitStore,
+
+ /// Delete history entries matching the configured exclusion filters
+ Prune {
+ /// List matching history lines without performing the actual deletion.
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+ },
+
+ /// Delete duplicate history entries (that have the same command, cwd and hostname)
+ Dedup {
+ /// List matching history lines without performing the actual deletion.
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+
+ /// Only delete results added before this date
+ #[arg(long, short)]
+ before: String,
+
+ /// How many recent duplicates to keep
+ #[arg(long)]
+ dupkeep: u32,
+ },
+}
+
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum ListMode {
+ Human,
+ CmdOnly,
+ Regular,
+}
+
+impl ListMode {
+ pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self {
+ if human {
+ Self::Human
+ } else if cmd_only {
+ Self::CmdOnly
+ } else {
+ Self::Regular
+ }
+ }
+}
+
+pub(crate) fn print_list(
+ h: &[History],
+ list_mode: ListMode,
+ format: Option<&str>,
+ print0: bool,
+ reverse: bool,
+ tz: Timezone,
+) {
+ let w = io::stdout();
+ let mut w = w.lock();
+
+ let fmt_str = match list_mode {
+ ListMode::Human => format
+ .unwrap_or("{time} · {duration}\t{command}")
+ .replace("\\t", "\t"),
+ ListMode::Regular => format
+ .unwrap_or("{time}\t{command}\t{duration}")
+ .replace("\\t", "\t"),
+ // not used
+ ListMode::CmdOnly => String::new(),
+ };
+
+ let parsed_fmt = match list_mode {
+ ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str),
+ ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(),
+ };
+
+ #[expect(trivial_casts)]
+ let iterator = if reverse {
+ Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>>
+ } else {
+ Box::new(h.iter()) as Box<dyn Iterator<Item = &History>>
+ };
+
+ let entry_terminator = if print0 { "\0" } else { "\n" };
+ let flush_each_line = print0;
+
+ for history in iterator {
+ let fh = FmtHistory {
+ history,
+ cmd_format: CmdFormat::for_output(&w),
+ tz: &tz,
+ };
+ let args = parsed_fmt.with_args(&fh);
+
+ // Check for formatting errors before attempting to write
+ if let Err(err) = args.status() {
+ eprintln!("ERROR: history output failed with: {err}");
+ std::process::exit(1);
+ }
+
+ let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+ write!(w, "{args}{entry_terminator}")
+ }));
+
+ match write_result {
+ Ok(Ok(())) => {
+ // Write succeeded
+ }
+ Ok(Err(err)) => {
+ if err.kind() != io::ErrorKind::BrokenPipe {
+ eprintln!("ERROR: Failed to write history output: {err}");
+ std::process::exit(1);
+ }
+ }
+ Err(_) => {
+ eprintln!("ERROR: Format string caused a formatting error.");
+ eprintln!(
+ "This may be due to an unsupported format string containing special characters."
+ );
+ eprintln!(
+ "Please check your format string syntax and ensure literal braces are properly escaped."
+ );
+ std::process::exit(1);
+ }
+ }
+ if flush_each_line {
+ check_for_write_errors(w.flush());
+ }
+ }
+
+ if !flush_each_line {
+ check_for_write_errors(w.flush());
+ }
+}
+
+fn check_for_write_errors(write: Result<(), io::Error>) {
+ if let Err(err) = write {
+ // Ignore broken pipe (issue #626)
+ if err.kind() != io::ErrorKind::BrokenPipe {
+ eprintln!("ERROR: History output failed with the following error: {err}");
+ std::process::exit(1);
+ }
+ }
+}
+
+/// Type wrapper around `History` with formatting settings.
+#[derive(Clone, Copy, Debug)]
+struct FmtHistory<'a> {
+ history: &'a History,
+ cmd_format: CmdFormat,
+ tz: &'a Timezone,
+}
+
+#[derive(Clone, Copy, Debug)]
+enum CmdFormat {
+ Literal,
+ Escaped,
+}
+impl CmdFormat {
+ fn for_output<O: IsTerminal>(out: &O) -> Self {
+ if out.is_terminal() {
+ Self::Escaped
+ } else {
+ Self::Literal
+ }
+ }
+}
+
+static TIME_FMT: &[time::format_description::FormatItem<'static>] =
+ format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]");
+
+/// defines how to format the history
+impl FormatKey for FmtHistory<'_> {
+ #[expect(clippy::cast_sign_loss)]
+ fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
+ match key {
+ "command" => match self.cmd_format {
+ CmdFormat::Literal => f.write_str(self.history.command.trim()),
+ CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()),
+ }?,
+ "directory" => f.write_str(self.history.cwd.trim())?,
+ "exit" => f.write_str(&self.history.exit.to_string())?,
+ "duration" => {
+ let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64);
+ format_duration_into(dur, f)?;
+ }
+ "time" => {
+ self.history
+ .timestamp
+ .to_offset(self.tz.0)
+ .format(TIME_FMT)
+ .map_err(|_| fmt::Error)?
+ .fmt(f)?;
+ }
+ "relativetime" => {
+ let since = OffsetDateTime::now_utc() - self.history.timestamp;
+ let d = Duration::try_from(since).unwrap_or_default();
+ format_duration_into(d, f)?;
+ }
+ "host" => f.write_str(
+ self.history
+ .hostname
+ .split_once(':')
+ .map_or(&self.history.hostname, |(host, _)| host),
+ )?,
+ "author" => f.write_str(&self.history.author)?,
+ "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?,
+ "user" => f.write_str(
+ self.history
+ .hostname
+ .split_once(':')
+ .map_or("", |(_, user)| user),
+ )?,
+ "session" => f.write_str(&self.history.session)?,
+ "uuid" => f.write_str(&self.history.id.0)?,
+ _ => return Err(FormatKeyError::UnknownKey),
+ }
+ Ok(())
+ }
+}
+
+fn parse_fmt(format: &str) -> ParsedFmt<'_> {
+ match ParsedFmt::new(format) {
+ Ok(fmt) => fmt,
+ Err(err) => {
+ eprintln!("ERROR: History formatting failed with the following error: {err}");
+
+ if format.contains('"') && (format.contains(":{") || format.contains(",{")) {
+ eprintln!("It looks like you're trying to create JSON output.");
+ eprintln!("For JSON, you need to escape literal braces by doubling them:");
+ eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'");
+ } else {
+ eprintln!(
+ "If your formatting string contains literal curly braces, you need to escape them by doubling:"
+ );
+ eprintln!("Use {{{{ for literal {{ and }}}} for literal }}");
+ }
+ std::process::exit(1)
+ }
+ }
+}
+
+fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) {
+ if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) {
+ author.clone_into(&mut history.author);
+ }
+
+ if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) {
+ history.intent = Some(intent.to_owned());
+ } else if intent.is_some() {
+ history.intent = None;
+ }
+}
+
+fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str {
+ if !settings.strip_trailing_whitespace {
+ return command;
+ }
+
+ let trimmed = command.trim_end_matches([' ', '\t']);
+ if trimmed.len() == command.len() {
+ return command;
+ }
+
+ let trailing_backslashes = trimmed
+ .as_bytes()
+ .iter()
+ .rev()
+ .take_while(|&&byte| byte == b'\\')
+ .count();
+
+ if trailing_backslashes % 2 == 1 {
+ command
+ } else {
+ trimmed
+ }
+}
+
+async fn handle_daemon_start(
+ settings: &Settings,
+ command: &str,
+ author: Option<&str>,
+ intent: Option<&str>,
+) -> Result<Option<String>> {
+ // It's better for atuin to silently fail here and attempt to
+ // store whatever is ran, than to throw an error to the terminal
+ let cwd = utils::get_current_dir();
+ let command = normalize_command_for_storage(command, settings);
+
+ let mut h: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command(command)
+ .cwd(cwd)
+ .build()
+ .into();
+ apply_start_metadata(&mut h, author, intent);
+
+ if !h.should_save(settings) {
+ return Ok(None);
+ }
+
+ // Attempt to start history via daemon, but silently ignore errors
+ // to avoid breaking the shell when the daemon is unavailable or disk is full
+ let resp = match daemon::start_history(settings, h.clone()).await {
+ Ok(id) => id,
+ Err(e) => {
+ debug!("failed to start history via daemon: {e}");
+ h.id.0.clone()
+ }
+ };
+
+ Ok(Some(resp))
+}
+
+async fn handle_daemon_end(
+ settings: &Settings,
+ id: &str,
+ exit: i64,
+ duration: Option<u64>,
+) -> Result<()> {
+ daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
+
+ Ok(())
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum TailKind {
+ Started,
+ Ended,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct TailEvent {
+ kind: TailKind,
+ history: History,
+}
+
+#[derive(Serialize)]
+struct TailJsonEvent<'a> {
+ event: &'static str,
+ history: TailJsonHistory<'a>,
+}
+
+#[derive(Serialize)]
+struct TailJsonHistory<'a> {
+ id: &'a str,
+ timestamp: String,
+ timestamp_unix_ns: u64,
+ command: &'a str,
+ cwd: &'a str,
+ session: &'a str,
+ hostname: &'a str,
+ host: &'a str,
+ user: &'a str,
+ author: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ intent: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ exit: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ duration_ns: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ duration: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ success: Option<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ finished_at: Option<String>,
+}
+
+impl TailEvent {
+ fn from_proto(reply: TailHistoryReply) -> Result<Self> {
+ let history = reply
+ .history
+ .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?;
+ let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(history.timestamp))
+ .context("invalid daemon history timestamp")?;
+ let kind = match HistoryEventKind::try_from(reply.kind)
+ .unwrap_or(HistoryEventKind::Unspecified)
+ {
+ HistoryEventKind::Started => TailKind::Started,
+ HistoryEventKind::Ended => TailKind::Ended,
+ HistoryEventKind::Unspecified => bail!("daemon sent an unspecified history tail event"),
+ };
+
+ Ok(Self {
+ kind,
+ history: History {
+ id: history.id.into(),
+ timestamp,
+ duration: history.duration,
+ exit: history.exit,
+ command: history.command,
+ cwd: history.cwd,
+ session: history.session,
+ hostname: history.hostname,
+ author: history.author,
+ intent: normalize_optional_field(&history.intent),
+ deleted_at: None,
+ },
+ })
+ }
+
+ fn render(&self, tty: bool, tz: Timezone) -> Result<String> {
+ if tty {
+ Ok(self.render_pretty(tz))
+ } else {
+ let mut json = self.render_json(tz)?;
+ json.push('\n');
+ Ok(json)
+ }
+ }
+
+ fn render_json(&self, tz: Timezone) -> Result<String> {
+ let payload = TailJsonEvent {
+ event: self.kind.as_str(),
+ history: TailJsonHistory {
+ id: &self.history.id.0,
+ timestamp: format_history_time(self.history.timestamp, tz)?,
+ timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos())
+ .context("history timestamp predates unix epoch")?,
+ command: &self.history.command,
+ cwd: &self.history.cwd,
+ session: &self.history.session,
+ hostname: &self.history.hostname,
+ host: self.host(),
+ user: self.user(),
+ author: &self.history.author,
+ intent: self.history.intent.as_deref(),
+ exit: self.exit_value(),
+ duration_ns: self.duration_value(),
+ duration: self.duration_value().map(format_duration_ns),
+ success: self.success_value(),
+ finished_at: self
+ .finished_at()
+ .map(|time| format_history_time(time, tz))
+ .transpose()?,
+ },
+ };
+
+ Ok(serde_json::to_string(&payload)?)
+ }
+
+ fn render_pretty(&self, tz: Timezone) -> String {
+ let mut out = String::new();
+ let border = match self.kind {
+ TailKind::Started => "-".repeat(72).bright_blue().to_string(),
+ TailKind::Ended if self.history.exit == 0 => "-".repeat(72).bright_green().to_string(),
+ TailKind::Ended => "-".repeat(72).bright_red().to_string(),
+ };
+
+ out.push_str(&border);
+ out.push('\n');
+
+ let command = self.history.command.trim();
+ let escaped_command = command.escape_control();
+ let mut command_lines = escaped_command.lines();
+ let header = format!(
+ "{} {}",
+ self.kind.badge(self.history.exit),
+ command_lines.next().unwrap_or_default().bold()
+ );
+ out.push_str(&header);
+ out.push('\n');
+
+ for line in command_lines {
+ out.push_str(" ");
+ out.push_str(line);
+ out.push('\n');
+ }
+
+ push_pretty_field(
+ &mut out,
+ "start",
+ &format_history_time(self.history.timestamp, tz)
+ .unwrap_or_else(|_| "invalid".to_owned()),
+ );
+ push_pretty_field(&mut out, "history", &self.history.id.0);
+ push_pretty_field(&mut out, "session", &self.history.session);
+ push_pretty_field(&mut out, "exit", &self.exit_display());
+ push_pretty_field(&mut out, "duration", &self.duration_display());
+
+ out.push('\n');
+
+ push_pretty_field(&mut out, "cwd", &self.history.cwd);
+ push_pretty_field(&mut out, "hostname", &self.history.hostname);
+ push_pretty_field(&mut out, "host", self.host());
+ push_pretty_field(&mut out, "user", self.user());
+ push_pretty_field(&mut out, "author", &self.history.author);
+
+ if let Some(intent) = self.history.intent.as_deref() {
+ push_pretty_field(&mut out, "intent", intent);
+ }
+
+ if let Some(finished) = self.finished_at() {
+ let finished =
+ format_history_time(finished, tz).unwrap_or_else(|_| "invalid".to_owned());
+ push_pretty_field(&mut out, "finished", &finished);
+ }
+
+ out.push_str(&border);
+ out.push_str("\n\n");
+ out
+ }
+
+ fn host(&self) -> &str {
+ self.history
+ .hostname
+ .split_once(':')
+ .map_or(self.history.hostname.as_str(), |(host, _)| host)
+ }
+
+ fn user(&self) -> &str {
+ self.history
+ .hostname
+ .split_once(':')
+ .map_or("", |(_, user)| user)
+ }
+
+ fn exit_value(&self) -> Option<i64> {
+ matches!(self.kind, TailKind::Ended).then_some(self.history.exit)
+ }
+
+ fn duration_value(&self) -> Option<i64> {
+ matches!(self.kind, TailKind::Ended).then_some(self.history.duration)
+ }
+
+ fn success_value(&self) -> Option<bool> {
+ matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0)
+ }
+
+ fn finished_at(&self) -> Option<OffsetDateTime> {
+ self.duration_value()
+ .filter(|duration| *duration >= 0)
+ .map(time::Duration::nanoseconds)
+ .and_then(|duration| self.history.timestamp.checked_add(duration))
+ }
+
+ fn exit_display(&self) -> String {
+ match self.exit_value() {
+ Some(0) => "0 (success)".bright_green().to_string(),
+ Some(code) => format!("{code} (failure)").bright_red().to_string(),
+ None => "pending".bright_yellow().to_string(),
+ }
+ }
+
+ fn duration_display(&self) -> String {
+ match self.duration_value() {
+ Some(duration) if duration >= 0 => format_duration_ns(duration),
+ Some(_) => "unknown".bright_yellow().to_string(),
+ None => "running".bright_yellow().to_string(),
+ }
+ }
+}
+
+impl TailKind {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Started => "started",
+ Self::Ended => "ended",
+ }
+ }
+
+ fn badge(self, exit: i64) -> colored::ColoredString {
+ match self {
+ Self::Started => "STARTED".bold().bright_blue(),
+ Self::Ended if exit == 0 => "ENDED".bold().bright_green(),
+ Self::Ended => "ENDED".bold().bright_red(),
+ }
+ }
+}
+
+fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
+ Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?)
+}
+
+fn format_duration_ns(duration_ns: i64) -> String {
+ struct F(Duration);
+ impl Display for F {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ format_duration_into(self.0, f)
+ }
+ }
+
+ F(Duration::from_nanos(duration_ns.max(0).cast_unsigned())).to_string()
+}
+
+fn push_pretty_field(out: &mut String, label: &str, value: &str) {
+ out.push_str(" ");
+ let label = format!("{label}:");
+ out.push_str(&label.bright_cyan().bold().to_string());
+ if label.len() < 10 {
+ out.push_str(&" ".repeat(10 - label.len()));
+ }
+
+ let mut lines = value.lines();
+ if let Some(first) = lines.next() {
+ out.push_str(first);
+ }
+ out.push('\n');
+
+ for line in lines {
+ out.push_str(" ");
+ out.push_str(line);
+ out.push('\n');
+ }
+}
+
+fn normalize_optional_field(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+impl Cmd {
+ async fn handle_tail(settings: &Settings) -> Result<()> {
+ let tty = io::stdout().is_terminal();
+ let mut client = daemon::tail_client(settings).await?;
+ let mut stream = client.tail_history().await?;
+ let stdout = io::stdout();
+
+ while let Some(reply) = stream.message().await? {
+ let event = TailEvent::from_proto(reply)?;
+ let rendered = event.render(tty, settings.timezone)?;
+ let mut out = stdout.lock();
+
+ match out.write_all(rendered.as_bytes()) {
+ Ok(()) => out.flush()?,
+ Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break,
+ Err(err) => return Err(err.into()),
+ }
+ }
+
+ Ok(())
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ #[expect(clippy::fn_params_excessive_bools)]
+ async fn handle_list(
+ db: &ClientSqlite,
+ settings: &Settings,
+ context: crate::atuin_client::database::Context,
+ session: bool,
+ cwd: bool,
+ mode: ListMode,
+ format: Option<String>,
+ include_deleted: bool,
+ print0: bool,
+ reverse: bool,
+ tz: Timezone,
+ ) -> Result<()> {
+ let filters = match (session, cwd) {
+ (true, true) => [Session, Directory],
+ (true, false) => [Session, Global],
+ (false, true) => [Global, Directory],
+ (false, false) => [
+ settings.default_filter_mode(context.git_root.is_some()),
+ Global,
+ ],
+ };
+
+ let history = db
+ .list(&filters, &context, None, false, include_deleted)
+ .await?;
+
+ print_list(
+ &history,
+ mode,
+ match format {
+ None => Some(settings.history_format.as_str()),
+ _ => format.as_deref(),
+ },
+ print0,
+ reverse,
+ tz,
+ );
+
+ Ok(())
+ }
+
+ async fn handle_prune(
+ db: &ClientSqlite,
+ settings: &Settings,
+ store: SqliteStore,
+ context: crate::atuin_client::database::Context,
+ dry_run: bool,
+ ) -> Result<()> {
+ // Grab all executed commands and filter them using History::should_save.
+ // We could iterate or paginate here if memory usage becomes an issue.
+ let matches: Vec<History> = db
+ .list(&[Global], &context, None, false, false)
+ .await?
+ .into_iter()
+ .filter(|h| !h.should_save(settings))
+ .collect();
+
+ match matches.len() {
+ 0 => {
+ println!("No entries to prune.");
+ return Ok(());
+ }
+ 1 => println!("Found 1 entry to prune."),
+ n => println!("Found {n} entries to prune."),
+ }
+
+ if dry_run {
+ print_list(
+ &matches,
+ ListMode::Human,
+ Some(settings.history_format.as_str()),
+ false,
+ false,
+ settings.timezone,
+ );
+ } else {
+ let encryption_key: [u8; 32] = encryption::load_key(settings)
+ .context("could not load encryption key")?
+ .into();
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ for entry in matches {
+ eprintln!("deleting {}", entry.id);
+ let (id, _) = history_store.delete(entry.id.clone()).await?;
+ history_store.incremental_build(db, &[id]).await?;
+ }
+
+ daemon::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryPruned).await;
+ }
+ Ok(())
+ }
+
+ async fn handle_dedup(
+ db: &ClientSqlite,
+ settings: &Settings,
+ store: SqliteStore,
+ before: i64,
+ dupkeep: u32,
+ dry_run: bool,
+ ) -> Result<()> {
+ if dupkeep == 0 {
+ eprintln!(
+ "\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of them! Use \"atuin search --delete ...\" if you really want that."
+ );
+ std::process::exit(1);
+ }
+
+ let matches: Vec<History> = db.get_dups(before, dupkeep).await?;
+
+ match matches.len() {
+ 0 => {
+ println!("No duplicates to delete.");
+ return Ok(());
+ }
+ 1 => println!("Found 1 duplicate to delete."),
+ n => println!("Found {n} duplicates to delete."),
+ }
+
+ if dry_run {
+ print_list(
+ &matches,
+ ListMode::Human,
+ Some(settings.history_format.as_str()),
+ false,
+ false,
+ settings.timezone,
+ );
+ } else {
+ let encryption_key: [u8; 32] = encryption::load_key(settings)
+ .context("could not load encryption key")?
+ .into();
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ let ids = matches.iter().map(|h| h.id.clone()).collect::<Vec<_>>();
+
+ for entry in matches {
+ eprintln!("deleting {}", entry.id);
+ let (id, _) = history_store.delete(entry.id).await?;
+ history_store.incremental_build(db, &[id]).await?;
+ }
+
+ daemon::emit_event(
+ settings,
+ crate::atuin_daemon::DaemonEvent::HistoryDeleted { ids },
+ )
+ .await;
+ }
+ Ok(())
+ }
+
+ #[expect(clippy::too_many_lines)]
+ pub(crate) async fn run(self, settings: &Settings) -> Result<()> {
+ match self {
+ Self::Start {
+ cmd_env,
+ author,
+ intent,
+ command,
+ } => {
+ let command = if cmd_env {
+ std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default()
+ } else {
+ command.join(" ")
+ };
+
+ if let Some(id) =
+ handle_daemon_start(settings, &command, author.as_deref(), intent.as_deref())
+ .await?
+ {
+ println!("{id}");
+ }
+
+ Ok(())
+ }
+ Self::End { id, exit, duration } => {
+ handle_daemon_end(settings, &id, exit, duration).await
+ }
+ Self::Tail => {
+ return Self::handle_tail(settings).await;
+ }
+ cmd => {
+ let context = current_context().await?;
+
+ let db_path = PathBuf::from(settings.db_path.as_str());
+ let record_store_path = PathBuf::from(settings.record_store_path.as_str());
+
+ let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
+ let store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
+
+ let encryption_key: [u8; 32] = encryption::load_key(settings)
+ .context("could not load encryption key")?
+ .into();
+
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ match cmd {
+ Self::List {
+ session,
+ cwd,
+ human,
+ cmd_only,
+ print0,
+ reverse,
+ timezone,
+ format,
+ } => {
+ let mode = ListMode::from_flags(human, cmd_only);
+ let tz = timezone.unwrap_or(settings.timezone);
+ Self::handle_list(
+ &db, settings, context, session, cwd, mode, format, false, print0,
+ reverse, tz,
+ )
+ .await
+ }
+
+ Self::Last {
+ human,
+ cmd_only,
+ timezone,
+ format,
+ } => {
+ let last = db.last().await?;
+ let last = last.as_slice();
+ let tz = timezone.unwrap_or(settings.timezone);
+ print_list(
+ last,
+ ListMode::from_flags(human, cmd_only),
+ match format {
+ None => Some(settings.history_format.as_str()),
+ _ => format.as_deref(),
+ },
+ false,
+ true,
+ tz,
+ );
+
+ Ok(())
+ }
+
+ Self::InitStore => history_store.init_store(&db).await,
+
+ Self::Prune { dry_run } => {
+ Self::handle_prune(&db, settings, store, context, dry_run).await
+ }
+
+ Self::Dedup {
+ dry_run,
+ before,
+ dupkeep,
+ } => {
+ let before = i64::try_from(
+ interim::parse_date_string(
+ before.as_str(),
+ OffsetDateTime::now_utc(),
+ interim::Dialect::Uk,
+ )?
+ .unix_timestamp_nanos(),
+ )?;
+ Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await
+ }
+
+ Self::Start { .. } | Self::End { .. } | Self::Tail => unreachable!(),
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use time::macros::datetime;
+
+ use super::{
+ History, Settings, TailEvent, TailKind, Timezone, normalize_command_for_storage, parse_fmt,
+ };
+
+ #[test]
+ fn normalize_command_strips_trailing_spaces_and_tabs() {
+ let settings = Settings::new().unwrap();
+
+ assert!(settings.strip_trailing_whitespace);
+ assert_eq!(normalize_command_for_storage("ls \t", &settings), "ls");
+ }
+
+ #[test]
+ fn normalize_command_preserves_escaped_trailing_space() {
+ let settings = Settings::new().unwrap();
+
+ assert_eq!(
+ normalize_command_for_storage("printf foo\\ ", &settings),
+ "printf foo\\ "
+ );
+ assert_eq!(
+ normalize_command_for_storage("printf foo\\\\ ", &settings),
+ "printf foo\\\\"
+ );
+ }
+
+ #[test]
+ fn test_format_string_no_panic() {
+ // Don't panic but provide helpful output (issue #2776)
+ let malformed_json = r#"{"command":"{command}","key":"value"}"#;
+
+ let result = std::panic::catch_unwind(|| parse_fmt(malformed_json));
+
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn test_valid_formats_still_work() {
+ assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok());
+ assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok());
+ }
+
+ fn sample_tail_event(kind: TailKind) -> TailEvent {
+ TailEvent {
+ kind,
+ history: History {
+ id: "history-id".to_owned().into(),
+ timestamp: datetime!(2026-04-09 17:18:19 UTC),
+ duration: 12_345_678,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/tmp/repo".to_owned(),
+ session: "session-id".to_owned(),
+ hostname: "host:ellie".to_owned(),
+ author: "claude".to_owned(),
+ intent: Some("inspect repository state".to_owned()),
+ deleted_at: None,
+ },
+ }
+ }
+
+ #[test]
+ fn test_tail_json_output_contains_history_fields() {
+ let json = sample_tail_event(TailKind::Ended)
+ .render(false, Timezone(time::UtcOffset::UTC))
+ .unwrap();
+ let value: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+ assert_eq!(value["event"], "ended");
+ assert_eq!(value["history"]["id"], "history-id");
+ assert_eq!(value["history"]["duration_ns"], 12_345_678);
+ assert_eq!(value["history"]["success"], true);
+ assert!(value.get("record").is_none());
+ }
+
+ #[test]
+ fn test_tail_pretty_output_shows_pending_fields_for_started_events() {
+ let rendered = sample_tail_event(TailKind::Started)
+ .render(true, Timezone(time::UtcOffset::UTC))
+ .unwrap();
+ let plain = regex::Regex::new(r"\x1b\[[0-9;]*m")
+ .unwrap()
+ .replace_all(&rendered, "");
+
+ assert!(plain.contains("STARTED git status"));
+ assert!(plain.contains("exit:"));
+ assert!(plain.contains("pending"));
+ assert!(plain.contains("duration:"));
+ assert!(plain.contains("running"));
+ }
+}
diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs
new file mode 100644
index 00000000..49c92193
--- /dev/null
+++ b/crates/client/src/command/client/info.rs
@@ -0,0 +1,34 @@
+use crate::atuin_client::settings::Settings;
+use crate::{SHA, VERSION};
+
+use eyre::Result;
+
+pub(crate) fn run(settings: &Settings) -> Result<()> {
+ let config = crate::atuin_common::utils::config_dir();
+ let mut config_file = config.clone();
+ config_file.push("config.toml");
+ let mut sever_config = config;
+ sever_config.push("server.toml");
+
+ let config_paths = format!(
+ "Config files:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nmeta db path: {:?}",
+ config_file.to_string_lossy(),
+ sever_config.to_string_lossy(),
+ settings.db_path,
+ settings.sync.encryption_key()?,
+ settings.meta.db_path
+ );
+
+ let env_vars = format!(
+ "Env Vars:\nATUIN_CONFIG_DIR = {:?}",
+ std::env::var("ATUIN_CONFIG_DIR").unwrap_or_else(|_| "None".into())
+ );
+
+ let general_info = format!("Version info:\nversion: {VERSION}\ncommit: {SHA}");
+
+ let print_out = format!("{config_paths}\n\n{env_vars}\n\n{general_info}");
+
+ println!("{print_out}");
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/init.rs b/crates/client/src/command/client/init.rs
new file mode 100644
index 00000000..0db9b143
--- /dev/null
+++ b/crates/client/src/command/client/init.rs
@@ -0,0 +1,129 @@
+use crate::atuin_client::settings::Settings;
+use clap::{Parser, ValueEnum};
+
+mod bash;
+mod fish;
+mod powershell;
+mod xonsh;
+mod zsh;
+
+#[derive(Parser, Debug)]
+pub(crate) struct Cmd {
+ shell: Shell,
+
+ /// Disable the binding of CTRL-R to atuin
+ #[clap(long)]
+ disable_ctrl_r: bool,
+
+ /// 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)]
+#[value(rename_all = "lower")]
+#[expect(clippy::enum_variant_names)]
+pub(crate) enum Shell {
+ /// Zsh setup
+ Zsh,
+
+ /// Bash setup
+ Bash,
+
+ /// Fish setup
+ Fish,
+
+ /// Nu setup
+ Nu,
+
+ /// Xonsh setup
+ Xonsh,
+
+ /// PowerShell setup
+ PowerShell,
+}
+
+impl Cmd {
+ fn init_nu(&self) {
+ let full = include_str!("../../shell/atuin.nu");
+
+ println!("{full}");
+
+ if std::env::var("ATUIN_NOBIND").is_err() {
+ const BIND_CTRL_R: &str = r"$env.config = (
+ $env.config | upsert keybindings (
+ $env.config.keybindings
+ | append {
+ name: atuin
+ modifier: control
+ keycode: char_r
+ mode: [emacs, vi_normal, vi_insert]
+ event: { send: executehostcommand cmd: (_atuin_search_cmd) }
+ }
+ )
+)";
+ const BIND_UP_ARROW: &str = r"
+$env.config = (
+ $env.config | upsert keybindings (
+ $env.config.keybindings
+ | append {
+ name: atuin
+ modifier: none
+ keycode: up
+ mode: [emacs, vi_normal, vi_insert]
+ event: {
+ until: [
+ {send: menuup}
+ {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') }
+ ]
+ }
+ }
+ )
+)
+";
+ if !self.disable_ctrl_r {
+ println!("{BIND_CTRL_R}");
+ }
+ if !self.disable_up_arrow {
+ println!("{BIND_UP_ARROW}");
+ }
+ }
+ }
+
+ fn static_init(&self) {
+ match self.shell {
+ Shell::Zsh => {
+ zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
+ }
+ Shell::Bash => {
+ bash::init_static(self.disable_up_arrow, self.disable_ctrl_r);
+ }
+ Shell::Fish => {
+ fish::init_static(self.disable_up_arrow, self.disable_ctrl_r);
+ }
+ Shell::Nu => {
+ self.init_nu();
+ }
+ Shell::Xonsh => {
+ xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
+ }
+ Shell::PowerShell => {
+ powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r);
+ }
+ }
+ }
+
+ pub(crate) fn run(self, settings: &Settings) {
+ if !settings.paths_ok() {
+ eprintln!(
+ "Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose."
+ );
+ }
+
+ self.static_init();
+ }
+}
diff --git a/crates/client/src/command/client/init/bash.rs b/crates/client/src/command/client/init/bash.rs
new file mode 100644
index 00000000..c16663e2
--- /dev/null
+++ b/crates/client/src/command/client/init/bash.rs
@@ -0,0 +1,13 @@
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
+ let base = include_str!("../../../shell/atuin.bash");
+
+ let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
+ (false, false)
+ } else {
+ (!disable_ctrl_r, !disable_up_arrow)
+ };
+
+ println!("__atuin_bind_ctrl_r={bind_ctrl_r}");
+ println!("__atuin_bind_up_arrow={bind_up_arrow}");
+ println!("{base}");
+}
diff --git a/crates/client/src/command/client/init/fish.rs b/crates/client/src/command/client/init/fish.rs
new file mode 100644
index 00000000..0a992b9c
--- /dev/null
+++ b/crates/client/src/command/client/init/fish.rs
@@ -0,0 +1,74 @@
+fn print_bindings(
+ indent: &str,
+ disable_up_arrow: bool,
+ disable_ctrl_r: bool,
+ bind_ctrl_r: &str,
+ bind_up_arrow: &str,
+ bind_ctrl_r_ins: &str,
+ bind_up_arrow_ins: &str,
+) {
+ if !disable_ctrl_r {
+ println!("{indent}{bind_ctrl_r}");
+ }
+ if !disable_up_arrow {
+ println!("{indent}{bind_up_arrow}");
+ }
+
+ println!("{indent}if bind -M insert >/dev/null 2>&1");
+ if !disable_ctrl_r {
+ println!("{indent}{indent}{bind_ctrl_r_ins}");
+ }
+ if !disable_up_arrow {
+ println!("{indent}{indent}{bind_up_arrow_ins}");
+ }
+ println!("{indent}end");
+}
+
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
+ let indent = " ".repeat(4);
+
+ let base = include_str!("../../../shell/atuin.fish");
+
+ println!("{base}");
+
+ if std::env::var("ATUIN_NOBIND").is_err() {
+ println!("if string match -q '4.*' $version");
+
+ // In fish 4.0 and above the option bind -k doesn't exist anymore,
+ // instead we can use key names and modifiers directly.
+ print_bindings(
+ &indent,
+ disable_up_arrow,
+ disable_ctrl_r,
+ "bind ctrl-r _atuin_search",
+ "bind up _atuin_bind_up",
+ "bind -M insert ctrl-r _atuin_search",
+ "bind -M insert up _atuin_bind_up",
+ );
+
+ println!("else");
+
+ // We keep these for compatibility with fish 3.x
+ print_bindings(
+ &indent,
+ disable_up_arrow,
+ disable_ctrl_r,
+ r"bind \cr _atuin_search",
+ &[
+ r"bind -k up _atuin_bind_up",
+ r"bind \eOA _atuin_bind_up",
+ r"bind \e\[A _atuin_bind_up",
+ ]
+ .join("; "),
+ r"bind -M insert \cr _atuin_search",
+ &[
+ r"bind -M insert -k up _atuin_bind_up",
+ r"bind -M insert \eOA _atuin_bind_up",
+ r"bind -M insert \e\[A _atuin_bind_up",
+ ]
+ .join("; "),
+ );
+
+ println!("end");
+ }
+}
diff --git a/crates/client/src/command/client/init/powershell.rs b/crates/client/src/command/client/init/powershell.rs
new file mode 100644
index 00000000..94d89c67
--- /dev/null
+++ b/crates/client/src/command/client/init/powershell.rs
@@ -0,0 +1,20 @@
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
+ let base = include_str!("../../../shell/atuin.ps1");
+
+ let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
+ (false, false)
+ } else {
+ (!disable_ctrl_r, !disable_up_arrow)
+ };
+
+ println!("{base}");
+ println!(
+ "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}",
+ ps_bool(bind_ctrl_r),
+ ps_bool(bind_up_arrow)
+ );
+}
+
+fn ps_bool(value: bool) -> &'static str {
+ if value { "$true" } else { "$false" }
+}
diff --git a/crates/client/src/command/client/init/xonsh.rs b/crates/client/src/command/client/init/xonsh.rs
new file mode 100644
index 00000000..25f867f7
--- /dev/null
+++ b/crates/client/src/command/client/init/xonsh.rs
@@ -0,0 +1,19 @@
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
+ let base = include_str!("../../../shell/atuin.xsh");
+
+ let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
+ (false, false)
+ } else {
+ (!disable_ctrl_r, !disable_up_arrow)
+ };
+
+ println!(
+ "_ATUIN_BIND_CTRL_R={}",
+ if bind_ctrl_r { "True" } else { "False" }
+ );
+ println!(
+ "_ATUIN_BIND_UP_ARROW={}",
+ if bind_up_arrow { "True" } else { "False" }
+ );
+ println!("{base}");
+}
diff --git a/crates/client/src/command/client/init/zsh.rs b/crates/client/src/command/client/init/zsh.rs
new file mode 100644
index 00000000..96a817d0
--- /dev/null
+++ b/crates/client/src/command/client/init/zsh.rs
@@ -0,0 +1,26 @@
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
+ let base = include_str!("../../../shell/atuin.zsh");
+
+ println!("{base}");
+
+ if std::env::var("ATUIN_NOBIND").is_err() {
+ const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search
+bindkey -M viins '^r' atuin-search-viins
+bindkey -M vicmd '/' atuin-search";
+
+ const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search
+bindkey -M vicmd '^[[A' atuin-up-search-vicmd
+bindkey -M viins '^[[A' atuin-up-search-viins
+bindkey -M emacs '^[OA' atuin-up-search
+bindkey -M vicmd '^[OA' atuin-up-search-vicmd
+bindkey -M viins '^[OA' atuin-up-search-viins
+bindkey -M vicmd 'k' atuin-up-search-vicmd";
+
+ if !disable_ctrl_r {
+ println!("{BIND_CTRL_R}");
+ }
+ if !disable_up_arrow {
+ println!("{BIND_UP_ARROW}");
+ }
+ }
+}
diff --git a/crates/client/src/command/client/search.rs b/crates/client/src/command/client/search.rs
new file mode 100644
index 00000000..359864cb
--- /dev/null
+++ b/crates/client/src/command/client/search.rs
@@ -0,0 +1,366 @@
+use std::fs::File;
+use std::io::{IsTerminal as _, Write, stderr, stdout};
+
+use crate::atuin_client::database::ClientSqlite;
+use crate::atuin_common::utils::{self, Escapable as _};
+use clap::Parser;
+use eyre::Result;
+
+use crate::atuin_client::{
+ database::{OptFilters, current_context},
+ encryption,
+ history::{History, store::HistoryStore},
+ record::sqlite_store::SqliteStore,
+ settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone},
+};
+
+use super::history::ListMode;
+
+mod cursor;
+mod duration;
+mod engines;
+mod history_list;
+mod inspector;
+mod interactive;
+pub(crate) mod keybindings;
+
+pub(crate) use duration::format_duration_into;
+
+#[expect(clippy::struct_excessive_bools, clippy::struct_field_names)]
+#[derive(Parser, Debug)]
+pub(crate) struct Cmd {
+ /// Filter search result by directory
+ #[arg(long, short)]
+ cwd: Option<String>,
+
+ /// Exclude directory from results
+ #[arg(long = "exclude-cwd")]
+ exclude_cwd: Option<String>,
+
+ /// Filter search result by exit code
+ #[arg(long, short)]
+ exit: Option<i64>,
+
+ /// Exclude results with this exit code
+ #[arg(long = "exclude-exit")]
+ exclude_exit: Option<i64>,
+
+ /// Only include results added before this date
+ #[arg(long, short)]
+ before: Option<String>,
+
+ /// Only include results after this date
+ #[arg(long)]
+ after: Option<String>,
+
+ /// How many entries to return at most
+ #[arg(long)]
+ limit: Option<i64>,
+
+ /// Offset from the start of the results
+ #[arg(long)]
+ offset: Option<i64>,
+
+ /// Open interactive search UI
+ #[arg(long, short)]
+ interactive: bool,
+
+ /// Allow overriding filter mode over config
+ #[arg(long = "filter-mode")]
+ filter_mode: Option<FilterMode>,
+
+ /// Allow overriding search mode over config
+ #[arg(long = "search-mode")]
+ search_mode: Option<SearchMode>,
+
+ /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion)
+ #[arg(long = "shell-up-key-binding", hide = true)]
+ shell_up_key_binding: bool,
+
+ /// Notify the keymap at the shell's side
+ #[arg(long = "keymap-mode", default_value = "auto")]
+ keymap_mode: KeymapMode,
+
+ /// Use human-readable formatting for time
+ #[arg(long)]
+ human: bool,
+
+ #[arg(allow_hyphen_values = true)]
+ query: Option<Vec<String>>,
+
+ /// Show only the text of the command
+ #[arg(long)]
+ cmd_only: bool,
+
+ /// Terminate the output with a null, for better multiline handling
+ #[arg(long)]
+ print0: bool,
+
+ /// Delete anything matching this query. Will not print out the match
+ #[arg(long)]
+ delete: bool,
+
+ /// Delete EVERYTHING!
+ #[arg(long)]
+ delete_it_all: bool,
+
+ /// Reverse the order of results, oldest first
+ #[arg(long, short)]
+ reverse: bool,
+
+ /// Display the command time in another timezone other than the configured default.
+ ///
+ /// This option takes one of the following kinds of values:
+ /// - the special value "local" (or "l") which refers to the system time zone
+ /// - an offset from UTC (e.g. "+9", "-2:30")
+ #[arg(long, visible_alias = "tz")]
+ #[arg(allow_hyphen_values = true)]
+ // Clippy warns about `Option<Option<T>>`, but we suppress it because we need
+ // this distinction for proper argument handling.
+ #[expect(clippy::option_option)]
+ timezone: Option<Option<Timezone>>,
+
+ /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and
+ /// {relativetime}.
+ /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
+ #[arg(long, short)]
+ format: Option<String>,
+
+ /// Set the maximum number of lines Atuin's interface should take up.
+ #[arg(long = "inline-height")]
+ inline_height: Option<u16>,
+
+ /// Filter by author. Supports $all-user (non-agents), $all-agent, or literal names.
+ /// Can be specified multiple times.
+ #[arg(long)]
+ author: Option<Vec<String>>,
+
+ /// Include duplicate commands in the output (non-interactive only)
+ #[arg(long)]
+ include_duplicates: bool,
+
+ /// File name to write the result to (hidden from help as this is meant to be used from a script)
+ #[arg(long = "result-file", hide = true)]
+ result_file: Option<String>,
+}
+
+impl Cmd {
+ /// Returns true if this search command will run in interactive (TUI) mode
+ pub(crate) fn is_interactive(&self) -> bool {
+ self.interactive
+ }
+
+ // clippy: please write this instead
+ // clippy: now it has too many lines
+ // me: I'll do it later OKAY
+ #[expect(clippy::too_many_lines)]
+ pub(crate) async fn run(
+ self,
+ db: ClientSqlite,
+ settings: &mut Settings,
+ store: SqliteStore,
+ ) -> Result<()> {
+ let query = self.query.unwrap_or_else(|| {
+ std::env::var("ATUIN_QUERY").map_or_else(
+ |_| vec![],
+ |query| query.split(' ').map(ToString::to_string).collect(),
+ )
+ });
+
+ if (self.delete_it_all || self.delete) && self.limit.is_some() {
+ // Because of how deletion is implemented, it will always delete all matches
+ // and disregard the limit option. It is also not clear what deletion with a
+ // limit would even mean. Deleting the LIMIT most recent entries that match
+ // the search query would make sense, but that wouldn't match what's displayed
+ // when running the equivalent search, but deleting those entries that are
+ // displayed with the search would leave any duplicates of those lines which may
+ // or may not have been intended to be deleted.
+ eprintln!("\"--limit\" is not compatible with deletion.");
+ return Ok(());
+ }
+
+ if self.delete && query.is_empty() {
+ eprintln!(
+ "Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all"
+ );
+ return Ok(());
+ }
+
+ if self.delete_it_all && !query.is_empty() {
+ eprintln!(
+ "--delete-it-all will delete ALL of your history! It does not require a query."
+ );
+ return Ok(());
+ }
+
+ if let Some(search_mode) = self.search_mode {
+ settings.search_mode = search_mode;
+ }
+ if let Some(filter_mode) = self.filter_mode {
+ settings.filter_mode = Some(filter_mode);
+ }
+ if let Some(inline_height) = self.inline_height {
+ settings.inline_height = inline_height;
+ }
+
+ settings.shell_up_key_binding = self.shell_up_key_binding;
+
+ // `keymap_mode` specified in config.toml overrides the `--keymap-mode`
+ // option specified in the keybindings.
+ settings.keymap_mode = match settings.keymap_mode {
+ KeymapMode::Auto => self.keymap_mode,
+ value => value,
+ };
+ settings.keymap_mode_shell = self.keymap_mode;
+
+ let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();
+
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ if self.interactive {
+ let item = interactive::history(&query, settings, db, &history_store).await?;
+
+ if let Some(result_file) = self.result_file {
+ let mut file = File::create(result_file)?;
+ write!(file, "{item}")?;
+ } else if !stdout().is_terminal() {
+ // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i)
+ // Write to stdout so it gets captured. This requires some care on Windows, as the current
+ // console code page or `[Console]::OutputEncoding` on PowerShell may be different from UTF-8.
+ println!("{item}");
+ } else if stderr().is_terminal() {
+ eprintln!("{}", item.escape_control());
+ } else {
+ eprintln!("{item}");
+ }
+ } else {
+ let opt_filter = OptFilters {
+ exit: self.exit,
+ exclude_exit: self.exclude_exit,
+ cwd: self.cwd,
+ exclude_cwd: self.exclude_cwd,
+ before: self.before,
+ after: self.after,
+ limit: self.limit,
+ offset: self.offset,
+ reverse: self.reverse,
+ include_duplicates: self.include_duplicates,
+ };
+
+ let mut entries =
+ run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
+
+ if entries.is_empty() {
+ std::process::exit(1)
+ }
+
+ // if we aren't deleting, print it all
+ if self.delete || self.delete_it_all {
+ // delete it
+ // it only took me _years_ to add this
+ // sorry
+ while !entries.is_empty() {
+ for entry in &entries {
+ eprintln!("deleting {}", entry.id);
+ }
+
+ let ids = history_store.delete_entries(entries).await?;
+ history_store.incremental_build(&db, &ids).await?;
+
+ entries =
+ run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
+ }
+ } else {
+ let format = match self.format {
+ None => Some(settings.history_format.as_str()),
+ _ => self.format.as_deref(),
+ };
+ let tz = match self.timezone {
+ Some(Some(tz)) => tz, // User provided a value
+ Some(None) | None => settings.timezone, // No value was provided
+ };
+
+ super::history::print_list(
+ &entries,
+ ListMode::from_flags(self.human, self.cmd_only),
+ format,
+ self.print0,
+ true,
+ tz,
+ );
+ }
+ }
+ Ok(())
+ }
+}
+
+// This is supposed to more-or-less mirror the command line version, so ofc
+// it is going to have a lot of args
+async fn run_non_interactive(
+ settings: &Settings,
+ filter_options: OptFilters,
+ query: &[String],
+ db: &ClientSqlite,
+) -> Result<Vec<History>> {
+ let dir = if filter_options.cwd.as_deref() == Some(".") {
+ Some(utils::get_current_dir())
+ } else {
+ filter_options.cwd
+ };
+
+ let context = current_context().await?;
+
+ let opt_filter = OptFilters {
+ cwd: dir.clone(),
+ ..filter_options
+ };
+
+ let filter_mode = settings.default_filter_mode(context.git_root.is_some());
+
+ let results = db
+ .search(
+ settings.search_mode,
+ filter_mode,
+ &context,
+ query.join(" ").as_str(),
+ opt_filter,
+ )
+ .await?;
+
+ Ok(results)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Cmd;
+ use clap::Parser;
+
+ #[test]
+ fn search_for_triple_dash() {
+ // Issue #3028: searching for `---` should not be treated as a CLI flag
+ let cmd = Cmd::try_parse_from(["search", "---"]);
+ assert!(cmd.is_ok(), "Failed to parse '---' as a query: {cmd:?}");
+ let cmd = cmd.unwrap();
+ assert_eq!(cmd.query, Some(vec!["---".to_string()]));
+ }
+
+ #[test]
+ fn search_for_double_dash_value() {
+ // Searching for strings starting with -- should also work
+ let cmd = Cmd::try_parse_from(["search", "--", "--foo"]);
+ assert!(cmd.is_ok());
+ let cmd = cmd.unwrap();
+ assert_eq!(cmd.query, Some(vec!["--foo".to_string()]));
+ }
+
+ #[test]
+ fn search_author_cli_flag() {
+ let cmd =
+ Cmd::try_parse_from(["search", "--author", "codex", "--author", "ellie"]).unwrap();
+ assert_eq!(
+ cmd.author,
+ Some(vec!["codex".to_string(), "ellie".to_string()])
+ );
+ }
+}
diff --git a/crates/client/src/command/client/search/cursor.rs b/crates/client/src/command/client/search/cursor.rs
new file mode 100644
index 00000000..e13e52b3
--- /dev/null
+++ b/crates/client/src/command/client/search/cursor.rs
@@ -0,0 +1,404 @@
+use crate::atuin_client::settings::WordJumpMode;
+
+pub(crate) struct Cursor {
+ source: String,
+ index: usize,
+}
+
+impl From<String> for Cursor {
+ fn from(source: String) -> Self {
+ Self { source, index: 0 }
+ }
+}
+
+pub(crate) struct WordJumper<'a> {
+ word_chars: &'a str,
+ word_jump_mode: WordJumpMode,
+}
+
+impl WordJumper<'_> {
+ fn is_word_boundary(&self, c: char, next_c: char) -> bool {
+ (c.is_whitespace() && !next_c.is_whitespace())
+ || (!c.is_whitespace() && next_c.is_whitespace())
+ || (self.word_chars.contains(c) && !self.word_chars.contains(next_c))
+ || (!self.word_chars.contains(c) && self.word_chars.contains(next_c))
+ }
+
+ fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (index + 1..source.len().saturating_sub(1))
+ .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(source.len());
+ (index + 1..source.len().saturating_sub(1))
+ .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(source.len())
+ }
+
+ fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (1..index)
+ .rev()
+ .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(0);
+ (1..index)
+ .rev()
+ .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .map_or(0, |i| i + 1)
+ }
+
+ fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (index..source.len().saturating_sub(1)).find(|&i| {
+ self.is_word_boundary(
+ source.chars().nth(i).unwrap(),
+ source.chars().nth(i + 1).unwrap(),
+ )
+ });
+ if index.is_none() {
+ return source.len();
+ }
+ (index.unwrap() + 1..source.len())
+ .find(|&i| !source.chars().nth(i).unwrap().is_whitespace())
+ .unwrap_or(source.len())
+ }
+
+ fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (1..index)
+ .rev()
+ .find(|&i| !source.chars().nth(i).unwrap().is_whitespace());
+ if index.is_none() {
+ return 0;
+ }
+ (1..index.unwrap())
+ .rev()
+ .find(|&i| {
+ self.is_word_boundary(
+ source.chars().nth(i - 1).unwrap(),
+ source.chars().nth(i).unwrap(),
+ )
+ })
+ .unwrap_or(0)
+ }
+
+ fn get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ match self.word_jump_mode {
+ WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index),
+ WordJumpMode::Subl => self.subl_get_next_word_pos(source, index),
+ }
+ }
+
+ fn get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ match self.word_jump_mode {
+ WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index),
+ WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index),
+ }
+ }
+}
+
+impl Cursor {
+ pub(crate) fn as_str(&self) -> &str {
+ self.source.as_str()
+ }
+
+ pub(crate) fn into_inner(self) -> String {
+ self.source
+ }
+
+ /// Returns the string before the cursor
+ pub(crate) fn substring(&self) -> &str {
+ &self.source[..self.index]
+ }
+
+ /// Returns the currently selected [`char`]
+ pub(crate) fn char(&self) -> Option<char> {
+ self.source[self.index..].chars().next()
+ }
+
+ pub(crate) fn right(&mut self) {
+ if self.index < self.source.len() {
+ loop {
+ self.index += 1;
+ if self.source.is_char_boundary(self.index) {
+ break;
+ }
+ }
+ }
+ }
+
+ pub(crate) fn left(&mut self) -> bool {
+ if self.index > 0 {
+ loop {
+ self.index -= 1;
+ if self.source.is_char_boundary(self.index) {
+ break true;
+ }
+ }
+ } else {
+ false
+ }
+ }
+
+ pub(crate) fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ self.index = word_jumper.get_next_word_pos(&self.source, self.index);
+ }
+
+ pub(crate) fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ self.index = word_jumper.get_prev_word_pos(&self.source, self.index);
+ }
+
+ /// Move cursor to the end of the current/next word (vim `e` motion).
+ ///
+ /// If cursor is in the middle of a word, moves to the end of that word.
+ /// If cursor is at the end of a word (or on whitespace), moves to the
+ /// end of the next word.
+ pub(crate) fn word_end(&mut self, word_chars: &str) {
+ let len = self.source.len();
+ if self.index >= len {
+ return;
+ }
+
+ let chars: Vec<char> = self.source.chars().collect();
+ let mut char_idx = self.source[..self.index].chars().count();
+
+ if char_idx >= chars.len() {
+ return;
+ }
+
+ let current = chars[char_idx];
+
+ // Check if we're at a word boundary (end of current word or on whitespace)
+ let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {
+ let next = chars[char_idx + 1];
+ next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))
+ };
+
+ // If at word boundary, advance past it and skip whitespace to find next word
+ if at_word_boundary {
+ char_idx += 1;
+ while char_idx < chars.len() && chars[char_idx].is_whitespace() {
+ char_idx += 1;
+ }
+ }
+
+ // If we've gone past end, go to end of string
+ if char_idx >= chars.len() {
+ self.index = len;
+ return;
+ }
+
+ // Find end of word: advance until next char is whitespace or different word type
+ let in_word_chars = word_chars.contains(chars[char_idx]);
+ while char_idx < chars.len() {
+ let next_idx = char_idx + 1;
+ if next_idx >= chars.len() {
+ // At last char, move past it
+ char_idx = next_idx;
+ break;
+ }
+ let next_c = chars[next_idx];
+ if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {
+ // Next char is start of new word/whitespace, so current char is end
+ char_idx = next_idx;
+ break;
+ }
+ char_idx += 1;
+ }
+
+ // Convert char index back to byte index
+ self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();
+ }
+
+ pub(crate) fn insert(&mut self, c: char) {
+ self.source.insert(self.index, c);
+ self.index += c.len_utf8();
+ }
+
+ pub(crate) fn remove(&mut self) -> Option<char> {
+ if self.index < self.source.len() {
+ Some(self.source.remove(self.index))
+ } else {
+ None
+ }
+ }
+
+ pub(crate) fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ let next_index = word_jumper.get_next_word_pos(&self.source, self.index);
+ self.source.replace_range(self.index..next_index, "");
+ }
+
+ pub(crate) fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ let next_index = word_jumper.get_prev_word_pos(&self.source, self.index);
+ self.source.replace_range(next_index..self.index, "");
+ self.index = next_index;
+ }
+
+ pub(crate) fn back(&mut self) -> Option<char> {
+ if self.left() { self.remove() } else { None }
+ }
+
+ pub(crate) fn clear(&mut self) {
+ self.source.clear();
+ self.index = 0;
+ }
+
+ pub(crate) fn clear_to_start(&mut self) {
+ self.source.replace_range(..self.index, "");
+ self.index = 0;
+ }
+
+ pub(crate) fn clear_to_end(&mut self) {
+ self.source.replace_range(self.index.., "");
+ self.index = self.source.len();
+ }
+
+ pub(crate) fn end(&mut self) {
+ self.index = self.source.len();
+ }
+
+ pub(crate) fn start(&mut self) {
+ self.index = 0;
+ }
+
+ pub(crate) fn position(&self) -> usize {
+ self.index
+ }
+}
+
+#[cfg(test)]
+mod cursor_tests {
+ use super::{Cursor, WordJumpMode, WordJumper};
+
+ static EMACS_WORD_JUMPER: WordJumper<'_> = WordJumper {
+ word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ word_jump_mode: WordJumpMode::Emacs,
+ };
+
+ static SUBL_WORD_JUMPER: WordJumper<'_> = WordJumper {
+ word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?",
+ word_jump_mode: WordJumpMode::Subl,
+ };
+
+ #[test]
+ fn right() {
+ // ö is 2 bytes
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];
+ for i in indices {
+ assert_eq!(c.index, i);
+ c.right();
+ }
+ }
+
+ #[test]
+ fn left() {
+ // ö is 2 bytes
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ c.end();
+ let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];
+ for i in indices {
+ assert_eq!(c.index, i);
+ c.left();
+ }
+ }
+
+ #[test]
+ fn test_emacs_get_next_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(0, 6), (3, 6), (7, 18), (19, 30)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_emacs_get_prev_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(30, 15), (29, 15), (15, 3), (3, 0)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_subl_get_next_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_subl_get_prev_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn pop() {
+ let mut s = String::from("öaöböcödöeöfö");
+ let mut c = Cursor::from(s.clone());
+ c.end();
+ while !s.is_empty() {
+ let c1 = s.pop();
+ let c2 = c.back();
+ assert_eq!(c1, c2);
+ assert_eq!(s.as_str(), c.substring());
+ }
+ let c1 = s.pop();
+ let c2 = c.back();
+ assert_eq!(c1, c2);
+ }
+
+ #[test]
+ fn back() {
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ // move to ^
+ for _ in 0..4 {
+ c.right();
+ }
+ assert_eq!(c.substring(), "öaöb");
+ assert_eq!(c.back(), Some('b'));
+ assert_eq!(c.back(), Some('ö'));
+ assert_eq!(c.back(), Some('a'));
+ assert_eq!(c.back(), Some('ö'));
+ assert_eq!(c.back(), None);
+ assert_eq!(c.as_str(), "öcödöeöfö");
+ }
+
+ #[test]
+ fn insert() {
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ // move to ^
+ for _ in 0..4 {
+ c.right();
+ }
+ assert_eq!(c.substring(), "öaöb");
+ c.insert('ö');
+ c.insert('g');
+ c.insert('ö');
+ c.insert('h');
+ assert_eq!(c.substring(), "öaöbögöh");
+ assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö");
+ }
+}
diff --git a/crates/client/src/command/client/search/duration.rs b/crates/client/src/command/client/search/duration.rs
new file mode 100644
index 00000000..0d70353a
--- /dev/null
+++ b/crates/client/src/command/client/search/duration.rs
@@ -0,0 +1,63 @@
+use core::fmt;
+use std::{ops::ControlFlow, time::Duration};
+
+pub(crate) fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {
+ if value > 0 {
+ ControlFlow::Break((unit, value))
+ } else {
+ ControlFlow::Continue(())
+ }
+ }
+
+ // impl taken and modified from
+ // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331
+ // Copyright (c) 2016 The humantime Developers
+ fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> {
+ let secs = f.as_secs();
+ let nanos = f.subsec_nanos();
+
+ let years = secs / 31_557_600; // 365.25d
+ let year_days = secs % 31_557_600;
+ let months = year_days / 2_630_016; // 30.44d
+ let month_days = year_days % 2_630_016;
+ let days = month_days / 86400;
+ let day_secs = month_days % 86400;
+ let hours = day_secs / 3600;
+ let minutes = day_secs % 3600 / 60;
+ let seconds = day_secs % 60;
+
+ let millis = nanos / 1_000_000;
+ let micros = nanos / 1_000;
+
+ // a difference from our impl than the original is that
+ // we only care about the most-significant segment of the duration.
+ // If the item call returns `Break`, then the `?` will early-return.
+ // This allows for a very consise impl
+ item("y", years)?;
+ item("mo", months)?;
+ item("d", days)?;
+ item("h", hours)?;
+ item("m", minutes)?;
+ item("s", seconds)?;
+ item("ms", u64::from(millis))?;
+ item("us", u64::from(micros))?;
+ item("ns", u64::from(nanos))?;
+ ControlFlow::Continue(())
+ }
+
+ match fmt(dur) {
+ ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"),
+ ControlFlow::Continue(()) => write!(f, "0s"),
+ }
+}
+
+pub(crate) fn format_duration(f: Duration) -> String {
+ struct F(Duration);
+ impl fmt::Display for F {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ format_duration_into(self.0, f)
+ }
+ }
+ F(f).to_string()
+}
diff --git a/crates/client/src/command/client/search/engines.rs b/crates/client/src/command/client/search/engines.rs
new file mode 100644
index 00000000..9fbff278
--- /dev/null
+++ b/crates/client/src/command/client/search/engines.rs
@@ -0,0 +1,86 @@
+use crate::atuin_client::{
+ database::{ClientSqlite, Context, OptFilters},
+ history::{History, HistoryId},
+ settings::{FilterMode, SearchMode, Settings},
+};
+use async_trait::async_trait;
+use eyre::Result;
+
+use super::cursor::Cursor;
+
+pub(crate) mod daemon;
+pub(crate) mod db;
+pub(crate) mod skim;
+
+pub(crate) fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> {
+ match search_mode {
+ SearchMode::Skim => Box::new(skim::Search::new()),
+ SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)),
+ mode => Box::new(db::Search(mode)),
+ }
+}
+
+pub(crate) struct SearchState {
+ pub(crate) input: Cursor,
+ pub(crate) filter_mode: FilterMode,
+ pub(crate) context: Context,
+ pub(crate) custom_context: Option<HistoryId>,
+}
+
+impl SearchState {
+ pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) {
+ let mut i = settings
+ .search
+ .filters
+ .iter()
+ .position(|&m| m == self.filter_mode)
+ .unwrap_or_default();
+ for _ in 0..settings.search.filters.len() {
+ i = (i.wrapping_add_signed(offset)) % settings.search.filters.len();
+ let mode = settings.search.filters[i];
+ if self.filter_mode_available(mode, settings) {
+ self.filter_mode = mode;
+ break;
+ }
+ }
+ }
+
+ fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {
+ match mode {
+ FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),
+ FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),
+ _ => true,
+ }
+ }
+}
+
+#[async_trait]
+pub(crate) trait SearchEngine: Send + Sync + 'static {
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut ClientSqlite,
+ ) -> Result<Vec<History>>;
+
+ async fn query(&mut self, state: &SearchState, db: &mut ClientSqlite) -> Result<Vec<History>> {
+ if state.input.as_str().is_empty() {
+ Ok(db
+ .search(
+ SearchMode::FullText,
+ state.filter_mode,
+ &state.context,
+ "",
+ OptFilters {
+ limit: Some(200),
+ ..Default::default()
+ },
+ )
+ .await?
+ .into_iter()
+ .collect::<Vec<_>>())
+ } else {
+ self.full_query(state, db).await
+ }
+ }
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>;
+}
diff --git a/crates/client/src/command/client/search/engines/daemon.rs b/crates/client/src/command/client/search/engines/daemon.rs
new file mode 100644
index 00000000..ee92ebaf
--- /dev/null
+++ b/crates/client/src/command/client/search/engines/daemon.rs
@@ -0,0 +1,213 @@
+use crate::atuin_client::{
+ database::{ClientSqlite, OptFilters},
+ history::History,
+ settings::{SearchMode, Settings},
+};
+use crate::atuin_daemon::client::SearchClient;
+use async_trait::async_trait;
+use atuin_nucleo_matcher::{
+ Config, Matcher, Utf32Str,
+ pattern::{CaseMatching, Normalization, Pattern},
+};
+use eyre::Result;
+use tracing::{Level, debug, instrument, span};
+use uuid::Uuid;
+
+use super::{SearchEngine, SearchState};
+
+pub(crate) struct Search {
+ client: Option<SearchClient>,
+ query_id: u64,
+ #[cfg(unix)]
+ socket_path: String,
+}
+
+impl Search {
+ pub(crate) fn new(settings: &Settings) -> Self {
+ Self {
+ client: None,
+ query_id: 0,
+ #[cfg(unix)]
+ socket_path: settings.daemon.socket_path.clone(),
+ }
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")]
+ async fn get_client(&mut self) -> Result<&mut SearchClient> {
+ if self.client.is_none() {
+ self.connect().await?;
+ }
+ Ok(self.client.as_mut().unwrap())
+ }
+
+ async fn connect(&mut self) -> Result<()> {
+ #[cfg(unix)]
+ let client = SearchClient::new(self.socket_path.clone()).await?;
+
+ self.client = Some(client);
+ Ok(())
+ }
+
+ fn next_query_id(&mut self) -> u64 {
+ self.query_id += 1;
+ self.query_id
+ }
+
+ /// Check if query contains regex pattern (r/.../)
+ /// Nucleo doesn't support regex, so we fall back to database search
+ fn contains_regex_pattern(query: &str) -> bool {
+ query.starts_with("r/") || query.contains(" r/")
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")]
+ async fn fallback_to_db_search(
+ &self,
+ state: &SearchState,
+ db: &ClientSqlite,
+ ) -> Result<Vec<History>> {
+ let results = db
+ .search(
+ SearchMode::FullText,
+ state.filter_mode,
+ &state.context,
+ state.input.as_str(),
+ OptFilters {
+ limit: Some(200),
+ ..Default::default()
+ },
+ )
+ .await
+ .map_or(Vec::new(), |r| r.into_iter().collect());
+ Ok(results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))]
+ async fn hydrate_from_db(&self, db: &ClientSqlite, ids: &[String]) -> Result<Vec<History>> {
+ let placeholders: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
+ let sql_query = format!(
+ "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC",
+ placeholders.join(",")
+ );
+ Ok(db.query_history(&sql_query).await?)
+ }
+}
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut ClientSqlite,
+ ) -> Result<Vec<History>> {
+ let query = state.input.as_str().to_string();
+
+ // Fall back to database for regex queries (Nucleo doesn't support regex)
+ if Self::contains_regex_pattern(&query) {
+ debug!(query = %query, "[daemon-client] regex detected, falling back to db");
+ return self.fallback_to_db_search(state, db).await;
+ }
+
+ let query_id = self.next_query_id();
+
+ let span =
+ span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id);
+
+ // Try to connect and search; if it fails with a retriable error,
+ // auto-start the daemon and retry once.
+ let first_attempt = async {
+ let client = self.get_client().await?;
+ client
+ .search(
+ query.clone(),
+ query_id,
+ state.filter_mode,
+ Some(state.context.clone()),
+ )
+ .await
+ }
+ .await;
+
+ let mut stream = match first_attempt {
+ Ok(stream) => stream,
+ Err(err) => return Err(err),
+ };
+
+ let mut ids = Vec::with_capacity(200);
+ span!(Level::TRACE, "daemon_search.resp")
+ .in_scope(async || {
+ while let Ok(Some(response)) = stream.message().await {
+ let span2 = span!(
+ Level::TRACE,
+ "daemon_search.resp.item",
+ query_id = response.query_id
+ );
+ let _span2 = span2.enter();
+ // Only process if the query_id matches (prevents stale responses)
+ if response.query_id == query_id {
+ let uuids = response
+ .ids
+ .iter()
+ .map(|id| {
+ let bytes: [u8; 16] =
+ id.as_slice().try_into().expect("id should be 16 bytes");
+ Uuid::from_bytes(bytes).as_simple().to_string()
+ })
+ .collect::<Vec<_>>();
+ ids.extend(uuids);
+ }
+ drop(_span2);
+ drop(span2);
+ }
+ })
+ .await;
+ drop(span);
+
+ if ids.is_empty() {
+ debug!(query = %query, results = 0, "[daemon-client] empty results");
+ return Ok(Vec::new());
+ }
+
+ // // Hydrate from local database
+ let results = self.hydrate_from_db(db, &ids).await?;
+
+ // // Reorder results to match the order from the daemon (which is ranked by relevance)
+ let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| {
+ let mut ordered_results = Vec::with_capacity(results.len());
+ for id in &ids {
+ if let Some(history) = results.iter().find(|h| h.id.0 == *id) {
+ ordered_results.push(history.clone());
+ }
+ }
+ ordered_results
+ });
+
+ debug!(
+ query = %query,
+ results = results.len(),
+ "[daemon-client]"
+ );
+
+ Ok(ordered_results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ // Use fulltext highlighting for regex queries
+ if Self::contains_regex_pattern(search_input) {
+ return super::db::get_highlight_indices_fulltext(command, search_input);
+ }
+
+ let mut matcher = Matcher::new(Config::DEFAULT);
+ let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart);
+
+ let mut indices: Vec<u32> = Vec::new();
+ let mut haystack_buf = Vec::new();
+
+ let haystack = Utf32Str::new(command, &mut haystack_buf);
+ pattern.indices(haystack, &mut matcher, &mut indices);
+
+ // Convert u32 indices to usize
+ indices.into_iter().map(|i| i as usize).collect()
+ }
+}
diff --git a/crates/client/src/command/client/search/engines/db.rs b/crates/client/src/command/client/search/engines/db.rs
new file mode 100644
index 00000000..0eb86878
--- /dev/null
+++ b/crates/client/src/command/client/search/engines/db.rs
@@ -0,0 +1,107 @@
+use super::{SearchEngine, SearchState};
+use crate::atuin_client::{
+ database::{ClientSqlite, OptFilters, QueryToken, QueryTokenizer},
+ history::History,
+ settings::SearchMode,
+};
+use async_trait::async_trait;
+use eyre::Result;
+use norm::Metric;
+use norm::fzf::{FzfParser, FzfV2};
+use std::ops::Range;
+use tracing::{Level, instrument};
+
+pub(crate) struct Search(pub(crate) SearchMode);
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut ClientSqlite,
+ ) -> Result<Vec<History>> {
+ let results = db
+ .search(
+ self.0,
+ state.filter_mode,
+ &state.context,
+ state.input.as_str(),
+ OptFilters {
+ limit: Some(200),
+ ..Default::default()
+ },
+ )
+ .await
+ // ignore errors as it may be caused by incomplete regex
+ .map_or(Vec::new(), |r| r.into_iter().collect());
+ Ok(results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ if self.0 == SearchMode::Prefix {
+ return vec![];
+ } else if self.0 == SearchMode::FullText {
+ return get_highlight_indices_fulltext(command, search_input);
+ }
+ let mut fzf = FzfV2::new();
+ let mut parser = FzfParser::new();
+ let query = parser.parse(search_input);
+ let mut ranges: Vec<Range<usize>> = Vec::new();
+ fzf.distance_and_ranges(query, command, &mut ranges);
+
+ // convert ranges to all indices
+ ranges.into_iter().flatten().collect()
+ }
+}
+
+#[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")]
+pub(crate) fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {
+ let mut ranges = vec![];
+ let lower_command = command.to_ascii_lowercase();
+
+ for token in QueryTokenizer::new(search_input) {
+ let matchee = if token.has_uppercase() {
+ command
+ } else {
+ &lower_command
+ };
+
+ if token.is_inverse() {
+ continue;
+ }
+
+ match token {
+ QueryToken::Or => {}
+ QueryToken::Regex(r) => {
+ if let Ok(re) = regex::Regex::new(r) {
+ for m in re.find_iter(command) {
+ ranges.push(m.range());
+ }
+ }
+ }
+ QueryToken::MatchStart(term, _) => {
+ if matchee.starts_with(term) {
+ ranges.push(0..term.len());
+ }
+ }
+ QueryToken::MatchEnd(term, _) => {
+ if matchee.ends_with(term) {
+ let l = matchee.len();
+ ranges.push((l - term.len())..l);
+ }
+ }
+ QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {
+ for (idx, m) in matchee.match_indices(term) {
+ ranges.push(idx..(idx + m.len()));
+ }
+ }
+ }
+ }
+
+ let mut ret: Vec<_> = ranges.into_iter().flatten().collect();
+ ret.sort_unstable();
+ ret.dedup();
+ ret
+}
diff --git a/crates/client/src/command/client/search/engines/skim.rs b/crates/client/src/command/client/search/engines/skim.rs
new file mode 100644
index 00000000..e090e40d
--- /dev/null
+++ b/crates/client/src/command/client/search/engines/skim.rs
@@ -0,0 +1,222 @@
+use std::path::Path;
+
+use crate::atuin_client::{database::ClientSqlite, history::History, settings::FilterMode};
+use async_trait::async_trait;
+use eyre::Result;
+use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
+use itertools::Itertools;
+use time::OffsetDateTime;
+use tokio::task::yield_now;
+use tracing::{Level, instrument, warn};
+
+use super::{SearchEngine, SearchState};
+
+pub(crate) struct Search {
+ all_history: Vec<(History, i32)>,
+ engine: SkimMatcherV2,
+}
+
+impl Search {
+ pub(crate) fn new() -> Self {
+ Self {
+ all_history: vec![],
+ engine: SkimMatcherV2::default(),
+ }
+ }
+}
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut ClientSqlite,
+ ) -> Result<Vec<History>> {
+ if self.all_history.is_empty() {
+ self.all_history = load_all_history(db).await;
+ }
+
+ Ok(fuzzy_search(&self.engine, state, &self.all_history).await)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ let (_, indices) = self
+ .engine
+ .fuzzy_indices(command, search_input)
+ .unwrap_or_default();
+ indices
+ }
+}
+
+#[instrument(skip_all, level = Level::TRACE, name = "load_all_history")]
+async fn load_all_history(db: &ClientSqlite) -> Vec<(History, i32)> {
+ db.all_with_count().await.unwrap()
+}
+
+#[expect(clippy::too_many_lines)]
+#[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))]
+async fn fuzzy_search(
+ engine: &SkimMatcherV2,
+ state: &SearchState,
+ all_history: &[(History, i32)],
+) -> Vec<History> {
+ let mut set = Vec::with_capacity(200);
+ let mut ranks = Vec::with_capacity(200);
+ let query = state.input.as_str();
+ let now = OffsetDateTime::now_utc();
+
+ for (i, (history, count)) in all_history.iter().enumerate() {
+ if i % 256 == 0 {
+ yield_now().await;
+ }
+
+ let context = &state.context;
+ let git_root = context
+ .git_root
+ .as_ref()
+ .and_then(|git_root| git_root.to_str())
+ .unwrap_or(&context.cwd);
+ match state.filter_mode {
+ FilterMode::Global => {}
+ // we aggregate host by ',' separating them
+ FilterMode::Host
+ if history
+ .hostname
+ .split(',')
+ .contains(&context.hostname.as_str()) => {}
+ // we aggregate session by concattenating them.
+ // sessions are 32 byte simple uuid formats
+ FilterMode::Session
+ if history
+ .session
+ .as_bytes()
+ .chunks(32)
+ .contains(&context.session.as_bytes()) => {}
+ // SessionPreload: include current session + global history from before session start
+ FilterMode::SessionPreload => {
+ let is_current_session = {
+ history
+ .session
+ .as_bytes()
+ .chunks(32)
+ .any(|chunk| chunk == context.session.as_bytes())
+ };
+
+ if !is_current_session {
+ let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else {
+ warn!("failed to parse session id '{}'", context.session);
+ continue;
+ };
+ let Some(timestamp) = uuid.get_timestamp() else {
+ warn!(
+ "failed to get timestamp from uuid '{}'",
+ uuid.as_hyphenated()
+ );
+ continue;
+ };
+ let (seconds, nanos) = timestamp.to_unix();
+ let Ok(session_start) = OffsetDateTime::from_unix_timestamp_nanos(
+ i128::from(seconds) * 1_000_000_000 + i128::from(nanos),
+ ) else {
+ warn!(
+ "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}"
+ );
+ continue;
+ };
+
+ if history.timestamp >= session_start {
+ continue;
+ }
+ }
+ }
+ // we aggregate directory by ':' separating them
+ FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {}
+ FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {}
+ _ => continue,
+ }
+ #[expect(clippy::cast_lossless, clippy::cast_precision_loss)]
+ if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) {
+ let begin = indices.first().copied().unwrap_or_default();
+
+ let mut duration = (now - history.timestamp).as_seconds_f64().log2();
+ if !duration.is_finite() || duration <= 1.0 {
+ duration = 1.0;
+ }
+ // these + X.0 just make the log result a bit smoother.
+ // log is very spiky towards 1-4, but I want a gradual decay.
+ // eg:
+ // log2(4) = 2, log2(5) = 2.3 (16% increase)
+ // log2(8) = 3, log2(9) = 3.16 (5% increase)
+ // log2(16) = 4, log2(17) = 4.08 (2% increase)
+ let count = (*count as f64 + 8.0).log2();
+ let begin = (begin as f64 + 16.0).log2();
+ let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref());
+ let path = (path as f64 + 8.0).log2();
+
+ // reduce longer durations, raise higher counts, raise matches close to the start
+ let score = (-score as f64) * count / path / duration / begin;
+
+ 'insert: {
+ // algorithm:
+ // 1. find either the position that this command ranks
+ // 2. find the same command positioned better than our rank.
+ for i in 0..set.len() {
+ // do we out score the current position?
+ if ranks[i] > score {
+ ranks.insert(i, score);
+ set.insert(i, history.clone());
+ let mut j = i + 1;
+ while j < set.len() {
+ // remove duplicates that have a worse score
+ if set[j].command == history.command {
+ ranks.remove(j);
+ set.remove(j);
+
+ // break this while loop because there won't be any other
+ // duplicates.
+ break;
+ }
+ j += 1;
+ }
+
+ // keep it limited
+ if ranks.len() > 200 {
+ ranks.pop();
+ set.pop();
+ }
+
+ break 'insert;
+ }
+ // don't continue if this command has a better score already
+ if set[i].command == history.command {
+ break 'insert;
+ }
+ }
+
+ if set.len() < 200 {
+ ranks.push(score);
+ set.push(history.clone());
+ }
+ }
+ }
+ }
+
+ set
+}
+
+fn path_dist(a: &Path, b: &Path) -> usize {
+ let mut a: Vec<_> = a.components().collect();
+ let b: Vec<_> = b.components().collect();
+
+ let mut dist = 0;
+
+ // pop a until there's a common ancestor
+ while !b.starts_with(&a) {
+ dist += 1;
+ a.pop();
+ }
+
+ b.len() - a.len() + dist
+}
diff --git a/crates/client/src/command/client/search/history_list.rs b/crates/client/src/command/client/search/history_list.rs
new file mode 100644
index 00000000..e46f37b7
--- /dev/null
+++ b/crates/client/src/command/client/search/history_list.rs
@@ -0,0 +1,431 @@
+use std::time::Duration;
+
+use super::duration::format_duration;
+use super::engines::SearchEngine;
+use crate::atuin_client::{
+ history::History,
+ settings::{UiColumn, UiColumnType},
+ theme::{
+ style_alerterror, style_alertinfo, style_alertwarn, style_annotation, style_base,
+ style_guidance,
+ },
+};
+use crate::atuin_common::utils::Escapable as _;
+use itertools::Itertools;
+use ratatui::{
+ backend::FromCrossterm,
+ buffer::Buffer,
+ crossterm::style,
+ layout::Rect,
+ style::{Modifier, Style},
+ widgets::{Block, StatefulWidget, Widget},
+};
+use time::OffsetDateTime;
+
+pub(crate) struct HistoryHighlighter<'a> {
+ pub(crate) engine: &'a dyn SearchEngine,
+ pub(crate) search_input: &'a str,
+}
+
+impl HistoryHighlighter<'_> {
+ pub(crate) fn get_highlight_indices(&self, command: &str) -> Vec<usize> {
+ self.engine
+ .get_highlight_indices(command, self.search_input)
+ }
+}
+
+pub(crate) struct HistoryList<'a> {
+ history: &'a [History],
+ block: Option<Block<'a>>,
+ inverted: bool,
+ /// Apply an alternative highlighting to the selected row
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ /// Columns to display (in order, after the indicator)
+ columns: &'a [UiColumn],
+}
+
+#[derive(Default)]
+pub(crate) struct ListState {
+ offset: usize,
+ selected: usize,
+ max_entries: usize,
+}
+
+impl ListState {
+ pub(crate) fn selected(&self) -> usize {
+ self.selected
+ }
+
+ pub(crate) fn max_entries(&self) -> usize {
+ self.max_entries
+ }
+
+ pub(crate) fn offset(&self) -> usize {
+ self.offset
+ }
+
+ pub(crate) fn select(&mut self, index: usize) {
+ self.selected = index;
+ }
+}
+
+impl StatefulWidget for HistoryList<'_> {
+ type State = ListState;
+
+ fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
+ let list_area = self.block.take().map_or(area, |b| {
+ let inner_area = b.inner(area);
+ b.render(area, buf);
+ inner_area
+ });
+
+ if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {
+ return;
+ }
+ let list_height = list_area.height as usize;
+
+ let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
+ state.offset = start;
+ state.max_entries = end - start;
+
+ let mut s = DrawState {
+ buf,
+ list_area,
+ x: 0,
+ y: 0,
+ state,
+ inverted: self.inverted,
+ alternate_highlight: self.alternate_highlight,
+ now: &self.now,
+ indicator: self.indicator,
+
+ history_highlighter: self.history_highlighter,
+ show_numeric_shortcuts: self.show_numeric_shortcuts,
+ columns: self.columns,
+ };
+
+ for item in self.history.iter().skip(state.offset).take(end - start) {
+ s.render_row(item);
+
+ // reset line
+ s.y += 1;
+ s.x = 0;
+ }
+ }
+}
+
+impl<'a> HistoryList<'a> {
+ #[expect(clippy::too_many_arguments)]
+ pub(crate) fn new(
+ history: &'a [History],
+ inverted: bool,
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+ ) -> Self {
+ Self {
+ history,
+ block: None,
+ inverted,
+ alternate_highlight,
+ now,
+ indicator,
+ history_highlighter,
+ show_numeric_shortcuts,
+ columns,
+ }
+ }
+
+ pub(crate) fn block(mut self, block: Block<'a>) -> Self {
+ self.block = Some(block);
+ self
+ }
+
+ fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {
+ let offset = offset.min(self.history.len().saturating_sub(1));
+
+ let max_scroll_space = height.min(10).min(self.history.len() - selected);
+ if offset + height < selected + max_scroll_space {
+ let end = selected + max_scroll_space;
+ (end - height, end)
+ } else if selected < offset {
+ (selected, selected + height)
+ } else {
+ (offset, offset + height)
+ }
+ }
+}
+
+struct DrawState<'a> {
+ buf: &'a mut Buffer,
+ list_area: Rect,
+ x: u16,
+ y: u16,
+ state: &'a ListState,
+ inverted: bool,
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+}
+
+// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
+// Yes, this is a hack, but it makes me feel happy
+static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
+
+impl DrawState<'_> {
+ /// Render a complete row for a history item based on configured columns.
+ fn render_row(&mut self, h: &History) {
+ // Always render the indicator first (width 3)
+ self.index();
+
+ // Calculate the width for the expanding column
+ // Fixed columns use their configured width + 1 (trailing space)
+ let indicator_width: u16 = 3;
+ let fixed_width: u16 = self
+ .columns
+ .iter()
+ .filter(|c| !c.expand)
+ .map(|c| c.width + 1)
+ .sum();
+ let expand_width = self
+ .list_area
+ .width
+ .saturating_sub(indicator_width + fixed_width);
+
+ let style = style_base();
+ // Render each configured column
+ for (idx, column) in self.columns.iter().enumerate() {
+ if idx != 0 {
+ self.draw(" ", Style::from_crossterm(style));
+ }
+ let width = if column.expand {
+ expand_width
+ } else {
+ column.width
+ };
+ match column.column_type {
+ UiColumnType::Duration => self.duration(h, width),
+ UiColumnType::Time => self.time(h, width),
+ UiColumnType::Datetime => self.datetime(h, width),
+ UiColumnType::Directory => self.directory(h, width),
+ UiColumnType::Host => self.host(h, width),
+ UiColumnType::User => self.user(h, width),
+ UiColumnType::Exit => self.exit_code(h, width),
+ UiColumnType::Command => self.command(h),
+ }
+ }
+ }
+
+ fn index(&mut self) {
+ if !self.show_numeric_shortcuts {
+ let i = self.y as usize + self.state.offset;
+ let is_selected = i == self.state.selected();
+ let prompt: &str = if is_selected { self.indicator } else { " " };
+ self.draw(prompt, Style::default());
+ return;
+ }
+
+ // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
+ // Yes, this is a hack, but it makes me feel happy
+
+ let i = self.y as usize + self.state.offset;
+ let i = i.checked_sub(self.state.selected);
+ let i = i.unwrap_or(10).min(10) * 2;
+ let prompt: &str = if i == 0 {
+ self.indicator
+ } else {
+ &SLICES[i..i + 3]
+ };
+ self.draw(prompt, Style::default());
+ }
+
+ fn duration(&mut self, h: &History, width: u16) {
+ let style = if h.success() {
+ style_alertinfo()
+ } else {
+ style_alerterror()
+ };
+ let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
+ let formatted = format_duration(duration);
+ let w = width as usize;
+ // Right-align duration within its column width, plus trailing space
+ let display = format!("{formatted:>w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn time(&mut self, h: &History, width: u16) {
+ let style = style_guidance();
+
+ // Account for the chance that h.timestamp is "in the future"
+ // This would mean that "since" is negative, and the unwrap here
+ // would fail.
+ // If the timestamp would otherwise be in the future, display
+ // the time since as 0.
+ let since = (self.now)() - h.timestamp;
+ let time = format_duration(since.try_into().unwrap_or_default());
+
+ // Format as "Xs ago" right-aligned within column width
+ let w = width as usize;
+ let time_str = format!("{time} ago");
+
+ let display = format!("{time_str:>w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn command(&mut self, h: &History) {
+ let mut style = style_base();
+ let mut row_highlighted = false;
+ if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
+ {
+ row_highlighted = true;
+ // if not applying alternative highlighting to the whole row, color the command
+ style = style_alerterror();
+ style.attributes.set(style::Attribute::Bold);
+ }
+
+ let highlight_indices = self.history_highlighter.get_highlight_indices(
+ h.command
+ .escape_control()
+ .split_ascii_whitespace()
+ .join(" ")
+ .as_str(),
+ );
+
+ let mut pos = 0;
+ for section in h.command.escape_control().split_ascii_whitespace() {
+ if pos != 0 {
+ self.draw(" ", Style::from_crossterm(style));
+ }
+ for ch in section.chars() {
+ if self.x > self.list_area.width {
+ // Avoid attempting to draw a command section beyond the width
+ // of the list
+ return;
+ }
+ let mut style = style;
+ if highlight_indices.contains(&pos) {
+ if row_highlighted {
+ // if the row is highlighted bold is not enough as the whole row is bold
+ // change the color too
+ style = style_alertwarn();
+ }
+ style.attributes.set(style::Attribute::Bold);
+ }
+ let s = ch.to_string();
+ self.draw(&s, Style::from_crossterm(style));
+ pos += s.len();
+ }
+ pos += 1;
+ }
+ }
+
+ /// Render the absolute datetime column (e.g., "2025-01-22 14:35")
+ fn datetime(&mut self, h: &History, width: u16) {
+ let style = style_annotation();
+ // Format: YYYY-MM-DD HH:MM
+ let formatted = h
+ .timestamp
+ .format(
+ &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
+ .expect("valid format"),
+ )
+ .unwrap_or_else(|_| "????-??-?? ??:??".to_string());
+ let w = width as usize;
+ let display = format!("{formatted:w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the directory column (working directory, truncated)
+ fn directory(&mut self, h: &History, width: u16) {
+ let style = style_annotation();
+ let w = width as usize;
+ let cwd = &h.cwd;
+ let char_count = cwd.chars().count();
+ // Truncate from the left with "..." if too long, plus trailing space
+ // Use character count for comparison and skip for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();
+ format!("...{truncated}")
+ } else {
+ format!("{cwd:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the host column (just the hostname)
+ fn host(&mut self, h: &History, width: u16) {
+ let style = style_annotation();
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
+ let char_count = host.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = host.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}...")
+ } else {
+ format!("{host:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the user column
+ fn user(&mut self, h: &History, width: u16) {
+ let style = style_annotation();
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let user = h.hostname.split(':').nth(1).unwrap_or("");
+ let char_count = user.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = user.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}...")
+ } else {
+ format!("{user:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the exit code column
+ fn exit_code(&mut self, h: &History, width: u16) {
+ let style = if h.success() {
+ style_alertinfo()
+ } else {
+ style_alerterror()
+ };
+ let w = width as usize;
+ let display = format!("{:>w$}", h.exit);
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn draw(&mut self, s: &str, mut style: Style) {
+ let cx = self.list_area.left() + self.x;
+
+ let cy = if self.inverted {
+ self.list_area.top() + self.y
+ } else {
+ self.list_area.bottom() - self.y - 1
+ };
+
+ if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
+ {
+ style = style.add_modifier(Modifier::REVERSED);
+ }
+
+ let w = (self.list_area.width - self.x) as usize;
+ self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;
+ }
+}
diff --git a/crates/client/src/command/client/search/inspector.rs b/crates/client/src/command/client/search/inspector.rs
new file mode 100644
index 00000000..186dcd3a
--- /dev/null
+++ b/crates/client/src/command/client/search/inspector.rs
@@ -0,0 +1,414 @@
+use std::time::Duration;
+use time::macros::format_description;
+
+use crate::atuin_client::{
+ history::{History, HistoryStats},
+ settings::{Settings, Timezone},
+ theme::{style_annotation, style_base, style_important},
+};
+use ratatui::{
+ Frame,
+ backend::FromCrossterm,
+ layout::Rect,
+ prelude::{Constraint, Direction, Layout},
+ style::Style,
+ text::{Span, Text},
+ widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
+};
+
+use super::duration::format_duration;
+
+use super::interactive::{Compactness, to_compactness};
+
+#[expect(clippy::cast_sign_loss)]
+fn u64_or_zero(num: i64) -> u64 {
+ if num < 0 { 0 } else { num as u64 }
+}
+
+pub(crate) fn draw_commands(
+ f: &mut Frame<'_>,
+ parent: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ compact: bool,
+) {
+ let commands = Layout::default()
+ .direction(if compact {
+ Direction::Vertical
+ } else {
+ Direction::Horizontal
+ })
+ .constraints(if compact {
+ [
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ]
+ } else {
+ [
+ Constraint::Ratio(1, 4),
+ Constraint::Ratio(1, 2),
+ Constraint::Ratio(1, 4),
+ ]
+ })
+ .split(parent);
+
+ let command = Paragraph::new(Text::from(Span::styled(
+ history.command.clone(),
+ Style::from_crossterm(style_important()),
+ )))
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(style_base()))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(style_base()))
+ .title("Command")
+ .padding(Padding::horizontal(1))
+ });
+
+ let previous = Paragraph::new(
+ stats
+ .previous
+ .clone()
+ .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
+ )
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(style_annotation()))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(style_annotation()))
+ .title("Previous command")
+ .padding(Padding::horizontal(1))
+ });
+
+ // Add [] around blank text, as when this is shown in a list
+ // compacted, it makes it more obviously control text.
+ let next = Paragraph::new(
+ stats
+ .next
+ .clone()
+ .map_or_else(|| "[No next command]".to_string(), |next| next.command),
+ )
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(style_annotation()))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .title("Next command")
+ .padding(Padding::horizontal(1))
+ .style(Style::from_crossterm(style_annotation()))
+ });
+
+ f.render_widget(previous, commands[0]);
+ f.render_widget(command, commands[1]);
+ f.render_widget(next, commands[2]);
+}
+
+pub(crate) fn draw_stats_table(
+ f: &mut Frame<'_>,
+ parent: Rect,
+ history: &History,
+ tz: Timezone,
+ stats: &HistoryStats,
+) {
+ let duration = Duration::from_nanos(u64_or_zero(history.duration));
+ let avg_duration = Duration::from_nanos(stats.average_duration);
+ let (host, user) = history.hostname.split_once(':').unwrap_or(("", ""));
+
+ let rows = [
+ Row::new(vec!["Host".to_string(), host.to_string()]),
+ Row::new(vec!["User".to_string(), user.to_string()]),
+ Row::new(vec![
+ "Time".to_string(),
+ history.timestamp.to_offset(tz.0).to_string(),
+ ]),
+ Row::new(vec!["Duration".to_string(), format_duration(duration)]),
+ Row::new(vec![
+ "Avg duration".to_string(),
+ format_duration(avg_duration),
+ ]),
+ Row::new(vec!["Exit".to_string(), history.exit.to_string()]),
+ Row::new(vec!["Directory".to_string(), history.cwd.clone()]),
+ Row::new(vec!["Session".to_string(), history.session.clone()]),
+ Row::new(vec!["Total runs".to_string(), stats.total.to_string()]),
+ ];
+
+ let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)];
+
+ let table = Table::new(rows, widths).column_spacing(1).block(
+ Block::default()
+ .title("Command stats")
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(style_base()))
+ .padding(Padding::vertical(1)),
+ );
+
+ f.render_widget(table, parent);
+}
+
+fn num_to_day(num: &str) -> String {
+ match num {
+ "0" => "Sunday".to_string(),
+ "1" => "Monday".to_string(),
+ "2" => "Tuesday".to_string(),
+ "3" => "Wednesday".to_string(),
+ "4" => "Thursday".to_string(),
+ "5" => "Friday".to_string(),
+ "6" => "Saturday".to_string(),
+ _ => "Invalid day".to_string(),
+ }
+}
+
+fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> {
+ let format = format_description!("[day]-[month]-[year]");
+ let output = format_description!("[month]/[year repr:last_two]");
+
+ let mut durations: Vec<(time::Date, i64)> = durations
+ .iter()
+ .map(|d| {
+ (
+ time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"),
+ d.1,
+ )
+ })
+ .collect();
+
+ durations.sort_by_key(|a| a.0);
+
+ durations
+ .iter()
+ .map(|(date, duration)| {
+ (
+ date.format(output).expect("failed to format sqlite date"),
+ *duration,
+ )
+ })
+ .collect()
+}
+
+fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) {
+ let exits: Vec<Bar<'_>> = stats
+ .exits
+ .iter()
+ .map(|(exit, count)| {
+ Bar::default()
+ .label(exit.to_string())
+ .value(u64_or_zero(*count))
+ })
+ .collect();
+
+ let exits = BarChart::default()
+ .block(
+ Block::default()
+ .title("Exit code distribution")
+ .style(Style::from_crossterm(style_base()))
+ .borders(Borders::ALL),
+ )
+ .bar_width(3)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&exits));
+
+ let day_of_week: Vec<Bar<'_>> = stats
+ .day_of_week
+ .iter()
+ .map(|(day, count)| {
+ Bar::default()
+ .label(num_to_day(day.as_str()))
+ .value(u64_or_zero(*count))
+ })
+ .collect();
+
+ let day_of_week = BarChart::default()
+ .block(
+ Block::default()
+ .title("Runs per day")
+ .style(Style::from_crossterm(style_base()))
+ .borders(Borders::ALL),
+ )
+ .bar_width(3)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&day_of_week));
+
+ let duration_over_time = sort_duration_over_time(&stats.duration_over_time);
+ let duration_over_time: Vec<Bar<'_>> = duration_over_time
+ .iter()
+ .map(|(date, duration)| {
+ let d = Duration::from_nanos(u64_or_zero(*duration));
+ Bar::default()
+ .label(date.clone())
+ .value(u64_or_zero(*duration))
+ .text_value(format_duration(d))
+ })
+ .collect();
+
+ let duration_over_time = BarChart::default()
+ .block(
+ Block::default()
+ .title("Duration over time")
+ .style(Style::from_crossterm(style_base()))
+ .borders(Borders::ALL),
+ )
+ .bar_width(5)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&duration_over_time));
+
+ let layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Ratio(1, 3),
+ Constraint::Ratio(1, 3),
+ Constraint::Ratio(1, 3),
+ ])
+ .split(parent);
+
+ f.render_widget(exits, layout[0]);
+ f.render_widget(day_of_week, layout[1]);
+ f.render_widget(duration_over_time, layout[2]);
+}
+
+pub(crate) fn draw(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ settings: &Settings,
+ tz: Timezone,
+) {
+ let compactness = to_compactness(f, settings);
+
+ match compactness {
+ Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats),
+ _ => draw_full(f, chunk, history, stats, tz),
+ }
+}
+
+pub(crate) fn draw_ultracompact(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+) {
+ draw_commands(f, chunk, history, stats, true);
+}
+
+pub(crate) fn draw_full(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ tz: Timezone,
+) {
+ let vert_layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)])
+ .split(chunk);
+
+ let stats_layout = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
+ .split(vert_layout[1]);
+
+ draw_commands(f, vert_layout[0], history, stats, false);
+ draw_stats_table(f, stats_layout[0], history, tz, stats);
+ draw_stats_charts(f, stats_layout[1], stats);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::draw_ultracompact;
+ use crate::atuin_client::history::{History, HistoryId, HistoryStats};
+ use ratatui::{
+ backend::TestBackend,
+ prelude::{Line, Rect, Terminal},
+ };
+ use time::OffsetDateTime;
+
+ fn mock_history_stats() -> (History, HistoryStats) {
+ let history = History {
+ id: HistoryId::from("test1".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 3,
+ exit: 0,
+ command: "/bin/cmd".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let next = History {
+ id: HistoryId::from("test2".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 2,
+ exit: 0,
+ command: "/bin/cmd -os".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let prev = History {
+ id: HistoryId::from("test3".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 1,
+ exit: 0,
+ command: "/bin/cmd -a".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let stats = HistoryStats {
+ next: Some(next.clone()),
+ previous: Some(prev.clone()),
+ total: 2,
+ average_duration: 3,
+ exits: Vec::new(),
+ day_of_week: Vec::new(),
+ duration_over_time: Vec::new(),
+ };
+ (history, stats)
+ }
+
+ #[test]
+ fn test_output_looks_correct_for_ultracompact() {
+ let backend = TestBackend::new(22, 5);
+ let mut terminal = Terminal::new(backend).expect("Could not create terminal");
+ let chunk = Rect::new(0, 0, 22, 5);
+ let (history, stats) = mock_history_stats();
+ let prev = stats.previous.clone().unwrap();
+ let next = stats.next.clone().unwrap();
+
+ drop(terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats)));
+ let mut lines = [" "; 5].map(|l| Line::from(l));
+ for (n, entry) in [prev, history, next].iter().enumerate() {
+ let mut l = lines[n].to_string();
+ l.replace_range(0..entry.command.len(), &entry.command);
+ lines[n] = Line::from(l);
+ }
+
+ terminal.backend().assert_buffer_lines(lines);
+ }
+}
diff --git a/crates/client/src/command/client/search/interactive.rs b/crates/client/src/command/client/search/interactive.rs
new file mode 100644
index 00000000..b8cf53db
--- /dev/null
+++ b/crates/client/src/command/client/search/interactive.rs
@@ -0,0 +1,3024 @@
+use std::{
+ io::{IsTerminal, Write, stdout},
+ time::Duration,
+};
+
+#[cfg(unix)]
+use std::io::Read as _;
+
+use crate::{
+ atuin_client::{
+ database::ClientSqlite,
+ theme::{style_annotation, style_base, style_important},
+ },
+ atuin_common::{shell::Shell, utils::Escapable as _},
+};
+use eyre::Result;
+use time::OffsetDateTime;
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+use super::{
+ cursor::Cursor,
+ engines::{SearchEngine, SearchState},
+ history_list::{HistoryList, ListState},
+};
+use crate::atuin_client::{
+ database::{Context, current_context},
+ history::{History, HistoryId, HistoryStats, store::HistoryStore},
+ settings::{
+ CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
+ UiColumn,
+ },
+};
+
+use crate::command::client::search::history_list::HistoryHighlighter;
+use crate::command::client::search::keybindings::KeymapSet;
+use crate::{VERSION, command::client::search::engines};
+
+use ratatui::{
+ Frame, Terminal, TerminalOptions, Viewport,
+ backend::{CrosstermBackend, FromCrossterm},
+ crossterm::{
+ cursor::SetCursorStyle,
+ event::{self, Event, KeyEvent, MouseEvent},
+ execute, queue, terminal,
+ },
+ layout::{Alignment, Constraint, Direction, Layout},
+ prelude::Rect,
+ style::{Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs},
+};
+
+#[cfg(not(target_os = "windows"))]
+use ratatui::crossterm::event::{
+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+};
+
+const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
+
+pub(crate) enum InputAction {
+ Accept(usize),
+ AcceptInspecting,
+ Copy(usize),
+ Delete(usize),
+ DeleteAllMatching(usize),
+ ReturnOriginal,
+ ReturnQuery,
+ Continue,
+ Redraw,
+ SwitchContext(Option<usize>),
+}
+
+#[derive(Clone)]
+pub(crate) struct InspectingState {
+ current: Option<HistoryId>,
+ next: Option<HistoryId>,
+ previous: Option<HistoryId>,
+}
+
+impl InspectingState {
+ pub(crate) fn move_to_previous(&mut self) {
+ let previous = self.previous.clone();
+ self.reset();
+ self.current = previous;
+ }
+
+ pub(crate) fn move_to_next(&mut self) {
+ let next = self.next.clone();
+ self.reset();
+ self.current = next;
+ }
+
+ pub(crate) fn reset(&mut self) {
+ self.current = None;
+ self.next = None;
+ self.previous = None;
+ }
+}
+
+pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness {
+ if match settings.style {
+ crate::atuin_client::settings::Style::Auto => f.area().height < 14,
+ crate::atuin_client::settings::Style::Compact => true,
+ crate::atuin_client::settings::Style::Full => false,
+ } {
+ if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
+ Compactness::Ultracompact
+ } else {
+ Compactness::Compact
+ }
+ } else {
+ Compactness::Full
+ }
+}
+
+#[expect(clippy::struct_field_names)]
+#[expect(clippy::struct_excessive_bools)]
+pub(crate) struct State {
+ history_count: i64,
+ results_state: ListState,
+ switched_search_mode: bool,
+ search_mode: SearchMode,
+ results_len: usize,
+ accept: bool,
+ keymap_mode: KeymapMode,
+ prefix: bool,
+ current_cursor: Option<CursorStyle>,
+ tab_index: usize,
+ pending_vim_key: Option<char>,
+ original_input_empty: bool,
+
+ pub(crate) inspecting_state: InspectingState,
+
+ keymaps: KeymapSet,
+ search: SearchState,
+ engine: Box<dyn SearchEngine>,
+ now: Box<dyn Fn() -> OffsetDateTime + Send>,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum Compactness {
+ Ultracompact,
+ Compact,
+ Full,
+}
+
+#[derive(Clone, Copy)]
+struct StyleState {
+ compactness: Compactness,
+ invert: bool,
+ inner_width: usize,
+}
+
+impl State {
+ async fn query_results(
+ &mut self,
+ db: &mut ClientSqlite,
+ smart_sort: bool,
+ ) -> Result<Vec<History>> {
+ let results = self.engine.query(&self.search, db).await?;
+
+ self.inspecting_state = InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ };
+ self.results_state.select(0);
+ self.results_len = results.len();
+
+ if smart_sort {
+ Ok(crate::atuin_history::sort::sort(
+ self.search.input.as_str(),
+ results,
+ ))
+ } else {
+ Ok(results)
+ }
+ }
+
+ fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction {
+ match input {
+ Event::Key(k) => self.handle_key_input(settings, k),
+ Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert),
+ Event::Paste(d) => self.handle_paste_input(d),
+ _ => InputAction::Continue,
+ }
+ }
+
+ fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction {
+ match (input.kind, inverted) {
+ (event::MouseEventKind::ScrollDown, false)
+ | (event::MouseEventKind::ScrollUp, true) => {
+ self.scroll_down(1);
+ }
+ (event::MouseEventKind::ScrollDown, true)
+ | (event::MouseEventKind::ScrollUp, false) => {
+ self.scroll_up(1);
+ }
+ _ => {}
+ }
+ InputAction::Continue
+ }
+
+ fn handle_paste_input(&mut self, input: &str) -> InputAction {
+ for i in input.chars() {
+ self.search.input.insert(i);
+ }
+ InputAction::Continue
+ }
+
+ fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle {
+ match style {
+ CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape,
+ CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,
+ CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,
+ CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore,
+ CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore,
+ CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,
+ CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,
+ }
+ }
+
+ fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) {
+ let cursor_style = if keymap_name == "__clear__" {
+ None
+ } else {
+ settings.keymap_cursor.get(keymap_name).copied()
+ }
+ .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape));
+
+ if cursor_style != self.current_cursor
+ && let Some(style) = cursor_style
+ {
+ self.current_cursor = cursor_style;
+ drop(execute!(stdout(), Self::cast_cursor_style(style)));
+ }
+ }
+
+ pub(crate) fn initialize_keymap_cursor(&mut self, settings: &Settings) {
+ match self.keymap_mode {
+ KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
+ KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
+ KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
+ KeymapMode::Auto => {}
+ }
+ }
+
+ pub(crate) fn finalize_keymap_cursor(&mut self, settings: &Settings) {
+ match settings.keymap_mode_shell {
+ KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
+ KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
+ KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
+ KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"),
+ }
+ }
+
+ fn handle_key_exit(settings: &Settings) -> InputAction {
+ match settings.exit_mode {
+ ExitMode::ReturnOriginal => InputAction::ReturnOriginal,
+ ExitMode::ReturnQuery => InputAction::ReturnQuery,
+ }
+ }
+
+ /// Select the keymap for the current mode (ignoring prefix).
+ fn mode_keymap(&self) -> &super::keybindings::Keymap {
+ if self.tab_index == 1 {
+ &self.keymaps.inspector
+ } else {
+ match self.keymap_mode {
+ KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs,
+ KeymapMode::VimNormal => &self.keymaps.vim_normal,
+ KeymapMode::VimInsert => &self.keymaps.vim_insert,
+ }
+ }
+ }
+
+ /// Whether the current mode supports character insertion on unmatched keys.
+ fn is_insert_mode(&self) -> bool {
+ matches!(
+ self.keymap_mode,
+ KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert
+ )
+ }
+
+ fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
+ use super::keybindings::Action;
+ use super::keybindings::EvalContext;
+ use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey};
+
+ // Skip release events
+ if input.kind == event::KeyEventKind::Release {
+ return InputAction::Continue;
+ }
+
+ // Reset switched_search_mode at start of each key event
+ self.switched_search_mode = false;
+
+ // Build evaluation context from current state
+ let ctx = EvalContext {
+ cursor_position: self.search.input.position(),
+ input_width: UnicodeWidthStr::width(self.search.input.as_str()),
+ input_byte_len: self.search.input.as_str().len(),
+ selected_index: self.results_state.selected(),
+ results_len: self.results_len,
+ original_input_empty: self.original_input_empty,
+ has_context: self.search.custom_context.is_some(),
+ };
+
+ // Convert KeyEvent to SingleKey
+ let Some(single) = SingleKey::from_event(input) else {
+ return InputAction::Continue;
+ };
+
+ // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---
+
+ // Take pending key before any immutable borrows of self
+ let pending = self.pending_vim_key.take();
+
+ // If in prefix mode, try prefix keymap first (single keys only)
+ let prefix_action = if self.prefix {
+ let ki = KeyInput::Single(single.clone());
+ self.keymaps.prefix.resolve(&ki, &ctx)
+ } else {
+ None
+ };
+
+ // The if-let/else-if chain here is clearer than map_or_else with nested closures.
+ #[expect(clippy::option_if_let_else)]
+ let (action, new_pending) = if prefix_action.is_some() {
+ (prefix_action, None)
+ } else {
+ // Use mode keymap (handles both single and multi-key sequences)
+ let keymap = self.mode_keymap();
+
+ if let Some(pending_char) = pending {
+ // We have a pending key from a previous press (e.g., first 'g' of 'gg')
+ let pending_single = SingleKey {
+ code: KeyCodeValue::Char(pending_char),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ };
+ let seq = KeyInput::Sequence(vec![pending_single, single.clone()]);
+ let action = keymap
+ .resolve(&seq, &ctx)
+ .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx));
+ (action, None)
+ } else if keymap.has_sequence_starting_with(&single)
+ && matches!(single.code, KeyCodeValue::Char(_))
+ && !single.ctrl
+ && !single.alt
+ {
+ // This key starts a multi-key sequence; wait for next key
+ let KeyCodeValue::Char(c) = single.code else {
+ unreachable!()
+ };
+ (Some(Action::Noop), Some(c))
+ } else {
+ (
+ keymap.resolve(&KeyInput::Single(single.clone()), &ctx),
+ None,
+ )
+ }
+ };
+
+ // --- Phase 2: Apply mutations ---
+ self.pending_vim_key = new_pending;
+
+ // Reset prefix (before execute, so EnterPrefixMode can re-set it)
+ self.prefix = false;
+
+ if let Some(action) = action {
+ self.execute_action(&action, settings)
+ } else {
+ // No action matched. In insert-capable modes, insert the character.
+ if self.is_insert_mode() && !single.ctrl && !single.alt {
+ match single.code {
+ KeyCodeValue::Char(c) => {
+ self.search.input.insert(c);
+ }
+ KeyCodeValue::Space => {
+ self.search.input.insert(' ');
+ }
+ _ => {}
+ }
+ }
+ InputAction::Continue
+ }
+ }
+
+ fn scroll_down(&mut self, scroll_len: usize) {
+ let i = self.results_state.selected().saturating_sub(scroll_len);
+ self.inspecting_state.reset();
+ self.results_state.select(i);
+ }
+
+ fn scroll_up(&mut self, scroll_len: usize) {
+ let i = self.results_state.selected() + scroll_len;
+ self.results_state
+ .select(i.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
+ }
+
+ /// Execute a resolved action, performing all side effects and returning the
+ /// appropriate `InputAction` for the event loop.
+ ///
+ /// This is the "do it" half of the resolve+execute pipeline. The resolver
+ /// decides *what* to do (which `Action`), and this function carries it out.
+ ///
+ /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account
+ /// for `settings.invert` so that keybindings are always in "visual" terms —
+ /// users never need to think about invert in their keybinding config.
+ #[expect(clippy::too_many_lines)]
+ pub(crate) fn execute_action(
+ &mut self,
+ action: &super::keybindings::Action,
+ settings: &Settings,
+ ) -> InputAction {
+ use crate::command::client::search::keybindings::Action;
+
+ match action {
+ // -- Cursor movement --
+ Action::CursorLeft => {
+ self.search.input.left();
+ InputAction::Continue
+ }
+ Action::CursorRight => {
+ self.search.input.right();
+ InputAction::Continue
+ }
+ Action::CursorWordLeft => {
+ self.search
+ .input
+ .prev_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::CursorWordRight => {
+ self.search
+ .input
+ .next_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::CursorWordEnd => {
+ self.search.input.word_end(&settings.word_chars);
+ InputAction::Continue
+ }
+ Action::CursorStart => {
+ self.search.input.start();
+ InputAction::Continue
+ }
+ Action::CursorEnd => {
+ self.search.input.end();
+ InputAction::Continue
+ }
+
+ // -- Editing --
+ Action::DeleteCharBefore => {
+ self.search.input.back();
+ InputAction::Continue
+ }
+ Action::DeleteCharAfter => {
+ self.search.input.remove();
+ InputAction::Continue
+ }
+ Action::DeleteWordBefore => {
+ self.search
+ .input
+ .remove_prev_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::DeleteWordAfter => {
+ self.search
+ .input
+ .remove_next_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::DeleteToWordBoundary => {
+ // ctrl-w: remove trailing whitespace, then delete to word boundary
+ while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}
+ while self.search.input.left() {
+ if self.search.input.char().unwrap().is_whitespace() {
+ self.search.input.right();
+ break;
+ }
+ self.search.input.remove();
+ }
+ InputAction::Continue
+ }
+ Action::ClearLine => {
+ self.search.input.clear();
+ InputAction::Continue
+ }
+ Action::ClearToStart => {
+ self.search.input.clear_to_start();
+ InputAction::Continue
+ }
+ Action::ClearToEnd => {
+ self.search.input.clear_to_end();
+ InputAction::Continue
+ }
+
+ // -- List navigation (invert-aware) --
+ Action::SelectNext => {
+ if settings.invert {
+ self.scroll_up(1);
+ } else {
+ self.scroll_down(1);
+ }
+ InputAction::Continue
+ }
+ Action::SelectPrevious => {
+ if settings.invert {
+ self.scroll_down(1);
+ } else {
+ self.scroll_up(1);
+ }
+ InputAction::Continue
+ }
+ // -- Page/half-page scroll (invert-aware) --
+ Action::ScrollHalfPageUp => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines)
+ / 2;
+ if settings.invert {
+ self.scroll_down(scroll_len);
+ } else {
+ self.scroll_up(scroll_len);
+ }
+ InputAction::Continue
+ }
+ Action::ScrollHalfPageDown => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines)
+ / 2;
+ if settings.invert {
+ self.scroll_up(scroll_len);
+ } else {
+ self.scroll_down(scroll_len);
+ }
+ InputAction::Continue
+ }
+ Action::ScrollPageUp => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines);
+ if settings.invert {
+ self.scroll_down(scroll_len);
+ } else {
+ self.scroll_up(scroll_len);
+ }
+ InputAction::Continue
+ }
+ Action::ScrollPageDown => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines);
+ if settings.invert {
+ self.scroll_up(scroll_len);
+ } else {
+ self.scroll_down(scroll_len);
+ }
+ InputAction::Continue
+ }
+
+ // -- Absolute jumps (invert-aware) --
+ Action::ScrollToTop => {
+ // Visual top of history
+ if settings.invert {
+ self.results_state.select(0);
+ } else {
+ let last_idx = self.results_len.saturating_sub(1);
+ self.results_state.select(last_idx);
+ }
+ self.inspecting_state.reset();
+ InputAction::Continue
+ }
+ Action::ScrollToBottom => {
+ // Visual bottom of history
+ if settings.invert {
+ let last_idx = self.results_len.saturating_sub(1);
+ self.results_state.select(last_idx);
+ } else {
+ self.results_state.select(0);
+ }
+ self.inspecting_state.reset();
+ InputAction::Continue
+ }
+ Action::ScrollToScreenTop => {
+ // H — jump to top of visible screen
+ let top = self.results_state.offset();
+ let visible = self.results_state.max_entries().min(self.results_len);
+ let bottom = top + visible.saturating_sub(1);
+ self.results_state
+ .select(bottom.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
+ InputAction::Continue
+ }
+ Action::ScrollToScreenMiddle => {
+ // M — jump to middle of visible screen
+ let top = self.results_state.offset();
+ let visible = self.results_state.max_entries().min(self.results_len);
+ let middle = top + visible / 2;
+ self.results_state
+ .select(middle.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
+ InputAction::Continue
+ }
+ Action::ScrollToScreenBottom => {
+ // L — jump to bottom of visible screen
+ let top_visible = self.results_state.offset();
+ self.results_state.select(top_visible);
+ self.inspecting_state.reset();
+ InputAction::Continue
+ }
+
+ // -- Commands --
+ Action::Accept => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
+ }
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected())
+ }
+ Action::AcceptNth(n) => {
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected() + *n as usize)
+ }
+ Action::ReturnSelection => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
+ }
+ InputAction::Accept(self.results_state.selected())
+ }
+ Action::ReturnSelectionNth(n) => {
+ InputAction::Accept(self.results_state.selected() + *n as usize)
+ }
+ Action::Copy => InputAction::Copy(self.results_state.selected()),
+ Action::Delete => InputAction::Delete(self.results_state.selected()),
+ Action::DeleteAll => InputAction::DeleteAllMatching(self.results_state.selected()),
+ Action::ReturnOriginal => InputAction::ReturnOriginal,
+ Action::ReturnQuery => InputAction::ReturnQuery,
+ Action::Exit => Self::handle_key_exit(settings),
+ Action::Redraw => InputAction::Redraw,
+ Action::CycleFilterMode => {
+ self.search.rotate_filter_mode(settings, 1);
+ InputAction::Continue
+ }
+ Action::CycleSearchMode => {
+ self.switched_search_mode = true;
+ self.search_mode = self.search_mode.next(settings);
+ self.engine = engines::engine(self.search_mode, settings);
+ InputAction::Continue
+ }
+ Action::SwitchContext => {
+ InputAction::SwitchContext(Some(self.results_state.selected()))
+ }
+ Action::ClearContext => InputAction::SwitchContext(None),
+ Action::ToggleTab => {
+ self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
+ InputAction::Continue
+ }
+
+ // -- Mode changes --
+ Action::VimEnterNormal => {
+ self.set_keymap_cursor(settings, "vim_normal");
+ self.keymap_mode = KeymapMode::VimNormal;
+ InputAction::Continue
+ }
+ Action::VimEnterInsert => {
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAfter => {
+ self.search.input.right();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAtStart => {
+ self.search.input.start();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAtEnd => {
+ self.search.input.end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimSearchInsert => {
+ self.search.input.clear();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimChangeToEnd => {
+ self.search.input.clear_to_end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::EnterPrefixMode => {
+ self.prefix = true;
+ InputAction::Continue
+ }
+
+ // -- Inspector --
+ Action::InspectPrevious => {
+ self.inspecting_state.move_to_previous();
+ InputAction::Redraw
+ }
+ Action::InspectNext => {
+ self.inspecting_state.move_to_next();
+ InputAction::Redraw
+ }
+
+ // -- Special --
+ Action::Noop => InputAction::Continue,
+ }
+ }
+
+ #[expect(clippy::cast_possible_truncation)]
+ #[expect(clippy::bool_to_int_with_if)]
+ fn calc_preview_height(
+ settings: &Settings,
+ results: &[History],
+ selected: usize,
+ tab_index: usize,
+ compactness: Compactness,
+ border_size: u16,
+ preview_width: u16,
+ ) -> u16 {
+ if settings.show_preview
+ && settings.preview.strategy == PreviewStrategy::Auto
+ && tab_index == 0
+ && !results.is_empty()
+ {
+ let length_current_cmd = results[selected].command.len() as u16;
+ // calculate the number of newlines in the command
+ let num_newlines = results[selected]
+ .command
+ .chars()
+ .filter(|&c| c == '\n')
+ .count() as u16;
+ if num_newlines > 0 {
+ std::cmp::min(
+ settings.max_preview_height,
+ results[selected]
+ .command
+ .split('\n')
+ .map(|line| {
+ (line.len() as u16 + preview_width - 1 - border_size)
+ / (preview_width - border_size)
+ })
+ .sum(),
+ ) + border_size * 2
+ }
+ // The '- 19' takes the characters before the command (duration and time) into account
+ else if length_current_cmd > preview_width - 19 {
+ std::cmp::min(
+ settings.max_preview_height,
+ (length_current_cmd + preview_width - 1 - border_size)
+ / (preview_width - border_size),
+ ) + border_size * 2
+ } else {
+ 1
+ }
+ } else if settings.show_preview
+ && settings.preview.strategy == PreviewStrategy::Static
+ && tab_index == 0
+ {
+ let longest_command = results
+ .iter()
+ .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
+ longest_command.map_or(0, |v| {
+ std::cmp::min(
+ settings.max_preview_height,
+ v.command
+ .split('\n')
+ .map(|line| {
+ (line.len() as u16 + preview_width - 1 - border_size)
+ / (preview_width - border_size)
+ })
+ .sum(),
+ )
+ }) + border_size * 2
+ } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
+ settings.max_preview_height + border_size * 2
+ } else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
+ 0
+ } else {
+ 1
+ }
+ }
+
+ fn draw(
+ &mut self,
+ f: &mut Frame<'_>,
+ results: &[History],
+ stats: Option<HistoryStats>,
+ inspecting: Option<&History>,
+ settings: &Settings,
+
+ popup_mode: bool,
+ ) {
+ let area = f.area();
+ if popup_mode {
+ f.render_widget(Clear, area);
+ }
+ self.draw_inner(f, area, results, stats, inspecting, settings);
+ }
+
+ #[expect(clippy::too_many_lines)]
+ #[expect(clippy::bool_to_int_with_if)]
+ fn draw_inner(
+ &mut self,
+ f: &mut Frame<'_>,
+ area: Rect,
+ results: &[History],
+ stats: Option<HistoryStats>,
+ inspecting: Option<&History>,
+ settings: &Settings,
+ ) {
+ let compactness = to_compactness(f, settings);
+ let invert = settings.invert;
+ let border_size = match compactness {
+ Compactness::Full => 1,
+ _ => 0,
+ };
+ let preview_width = area.width.saturating_sub(2);
+ let preview_height = Self::calc_preview_height(
+ settings,
+ results,
+ self.results_state.selected(),
+ self.tab_index,
+ compactness,
+ border_size,
+ preview_width,
+ );
+ let show_help =
+ settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1);
+ // This is an OR, as it seems more likely for someone to wish to override
+ // tabs unexpectedly being missed, than unexpectedly present.
+ let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(0)
+ .horizontal_margin(1)
+ .constraints::<&[Constraint]>(
+ if invert {
+ [
+ Constraint::Length(1 + border_size), // input
+ Constraint::Min(1), // results list
+ Constraint::Length(preview_height), // preview
+ Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
+ Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
+ ]
+ } else {
+ match compactness {
+ Compactness::Ultracompact => [
+ Constraint::Length(if show_help { 1 } else { 0 }), // header
+ Constraint::Length(0), // tabs
+ Constraint::Min(1), // results list
+ Constraint::Length(0),
+ Constraint::Length(0),
+ ],
+ _ => [
+ Constraint::Length(if show_help { 1 } else { 0 }), // header
+ Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
+ Constraint::Min(1), // results list
+ Constraint::Length(1 + border_size), // input
+ Constraint::Length(preview_height), // preview
+ ],
+ }
+ }
+ .as_ref(),
+ )
+ .split(area);
+
+ let input_chunk = if invert { chunks[0] } else { chunks[3] };
+ let results_list_chunk = if invert { chunks[1] } else { chunks[2] };
+ let preview_chunk = if invert { chunks[2] } else { chunks[4] };
+ let tabs_chunk = if invert { chunks[3] } else { chunks[1] };
+ let header_chunk = if invert { chunks[4] } else { chunks[0] };
+
+ // TODO: this should be split so that we have one interactive search container that is
+ // EITHER a search box or an inspector. But I'm not doing that now, way too much atm.
+ // also allocate less 🙈
+ let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect();
+
+ if show_tabs {
+ let tabs = Tabs::new(titles)
+ .block(Block::default().borders(Borders::NONE))
+ .select(self.tab_index)
+ .style(Style::default())
+ .highlight_style(Style::from_crossterm(style_important()));
+
+ f.render_widget(tabs, tabs_chunk);
+ }
+
+ let style = StyleState {
+ compactness,
+ invert,
+ inner_width: input_chunk.width.into(),
+ };
+
+ let header_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints::<&[Constraint]>(
+ [
+ Constraint::Ratio(1, 5),
+ Constraint::Ratio(3, 5),
+ Constraint::Ratio(1, 5),
+ ]
+ .as_ref(),
+ )
+ .split(header_chunk);
+
+ let title = Self::build_title();
+ f.render_widget(title, header_chunks[0]);
+
+ let help = self.build_help(settings);
+ f.render_widget(help, header_chunks[1]);
+
+ let stats_tab = self.build_stats();
+ f.render_widget(stats_tab, header_chunks[2]);
+
+ let indicator: String = match compactness {
+ Compactness::Ultracompact => {
+ if self.switched_search_mode {
+ format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
+ } else if self.search.custom_context.is_some() {
+ format!(
+ "C{}>",
+ self.search.filter_mode.as_str().chars().next().unwrap()
+ )
+ } else {
+ format!(
+ "{}> ",
+ self.search.filter_mode.as_str().chars().next().unwrap()
+ )
+ }
+ }
+ _ => " > ".to_string(),
+ };
+
+ match self.tab_index {
+ 0 => {
+ let history_highlighter = HistoryHighlighter {
+ engine: self.engine.as_ref(),
+ search_input: self.search.input.as_str(),
+ };
+ let results_list = Self::build_results_list(
+ style,
+ results,
+ self.keymap_mode,
+ &self.now,
+ indicator.as_str(),
+ history_highlighter,
+ settings.show_numeric_shortcuts,
+ &settings.ui.columns,
+ );
+ f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
+ }
+
+ 1 => {
+ if results.is_empty() {
+ let message = Paragraph::new("Nothing to inspect")
+ .block(
+ Block::new()
+ .title(Line::from(" Info ".to_string()))
+ .title_alignment(Alignment::Center)
+ .borders(Borders::ALL)
+ .padding(Padding::vertical(2)),
+ )
+ .alignment(Alignment::Center);
+ f.render_widget(message, results_list_chunk);
+ } else {
+ let inspecting = match inspecting {
+ Some(inspecting) => inspecting,
+ None => &results[self.results_state.selected()],
+ };
+ super::inspector::draw(
+ f,
+ results_list_chunk,
+ inspecting,
+ &stats.expect("Drawing inspector, but no stats"),
+ settings,
+ settings.timezone,
+ );
+ }
+
+ // HACK: I'm following up with abstracting this into the UI container, with a
+ // sub-widget for search + for inspector
+ let feedback = Paragraph::new(
+ "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh",
+ );
+ f.render_widget(feedback, input_chunk);
+
+ return;
+ }
+
+ _ => {
+ panic!("invalid tab index");
+ }
+ }
+
+ if !matches!(compactness, Compactness::Ultracompact) {
+ let preview_width = match compactness {
+ Compactness::Full => preview_width - 2,
+ _ => preview_width,
+ };
+ let preview = self.build_preview(
+ results,
+ compactness,
+ preview_width,
+ preview_chunk.width.into(),
+ );
+ #[expect(clippy::cast_possible_truncation)]
+ let prefix_width = settings
+ .ui
+ .columns
+ .iter()
+ .take_while(|col| !col.expand)
+ .map(|col| col.width + 1)
+ .sum::<u16>()
+ + " > ".len() as u16;
+ #[expect(clippy::cast_possible_truncation)]
+ let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16;
+ self.draw_preview(
+ f,
+ style,
+ input_chunk,
+ compactness,
+ preview_chunk,
+ preview,
+ std::cmp::max(prefix_width, min_prefix_width),
+ );
+ }
+ }
+
+ #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)]
+ fn draw_preview(
+ &self,
+ f: &mut Frame<'_>,
+ style: StyleState,
+ input_chunk: Rect,
+ compactness: Compactness,
+ preview_chunk: Rect,
+ preview: Paragraph<'_>,
+ prefix_width: u16,
+ ) {
+ let input = self.build_input(style, prefix_width);
+ f.render_widget(input, input_chunk);
+
+ f.render_widget(preview, preview_chunk);
+
+ let extra_width = UnicodeWidthStr::width(self.search.input.substring());
+
+ let cursor_offset = match compactness {
+ Compactness::Full => 1,
+ _ => 0,
+ };
+ f.set_cursor_position((
+ // Put cursor past the end of the input text
+ input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,
+ input_chunk.y + cursor_offset,
+ ));
+ }
+
+ fn build_title<'a>() -> Paragraph<'a> {
+ let title = {
+ let style: Style = Style::from_crossterm(style_base());
+ Paragraph::new(Text::from(Span::styled(
+ format!("Atuin v{VERSION}"),
+ style.add_modifier(Modifier::BOLD),
+ )))
+ };
+ title.alignment(Alignment::Left)
+ }
+
+ fn build_help(&self, settings: &Settings) -> Paragraph<'_> {
+ match self.tab_index {
+ // search
+ 0 => Paragraph::new(Text::from(Line::from(vec![
+ Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": exit"),
+ Span::raw(", "),
+ Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": edit"),
+ Span::raw(", "),
+ Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(if settings.enter_accept {
+ ": run"
+ } else {
+ ": edit"
+ }),
+ Span::raw(", "),
+ Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": inspect"),
+ ]))),
+
+ 1 => Paragraph::new(Text::from(Line::from(vec![
+ Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": exit"),
+ Span::raw(", "),
+ Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": search"),
+ Span::raw(", "),
+ Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": delete"),
+ ]))),
+
+ _ => unreachable!("invalid tab index"),
+ }
+ .style(Style::from_crossterm(style_annotation()))
+ .alignment(Alignment::Center)
+ }
+
+ fn build_stats(&self) -> Paragraph<'_> {
+ Paragraph::new(Text::from(Span::raw(format!(
+ "history count: {}",
+ self.history_count,
+ ))))
+ .style(Style::from_crossterm(style_annotation()))
+ .alignment(Alignment::Right)
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ fn build_results_list<'a>(
+ style: StyleState,
+ results: &'a [History],
+ keymap_mode: KeymapMode,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+ ) -> HistoryList<'a> {
+ let results_list = HistoryList::new(
+ results,
+ style.invert,
+ keymap_mode == KeymapMode::VimNormal,
+ now,
+ indicator,
+ history_highlighter,
+ show_numeric_shortcuts,
+ columns,
+ );
+
+ match style.compactness {
+ Compactness::Full => {
+ if style.invert {
+ results_list.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
+ )
+ } else {
+ results_list.block(
+ Block::default()
+ .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded),
+ )
+ }
+ }
+ _ => results_list,
+ }
+ }
+
+ fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {
+ let (pref, mode) = if self.switched_search_mode {
+ (" SRCH:", self.search_mode.as_str())
+ } else if self.search.custom_context.is_some() {
+ (" CTX:", self.search.filter_mode.as_str())
+ } else {
+ ("", self.search.filter_mode.as_str())
+ };
+ // 3: surrounding "[" "] "
+ let mode_width = usize::from(prefix_width) - pref.len() - 3;
+ // sanity check to ensure we don't exceed the layout limits
+ debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
+ let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str());
+ let input = Paragraph::new(input);
+ match style.compactness {
+ Compactness::Full => {
+ if style.invert {
+ input.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
+ .border_type(BorderType::Rounded),
+ )
+ } else {
+ input.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
+ )
+ }
+ }
+ _ => input,
+ }
+ }
+
+ fn build_preview(
+ &self,
+ results: &[History],
+ compactness: Compactness,
+ preview_width: u16,
+ chunk_width: usize,
+ ) -> Paragraph<'_> {
+ let selected = self.results_state.selected();
+ let command = if results.is_empty() {
+ String::new()
+ } else {
+ let s = &results[selected].command;
+ let mut lines = Vec::new();
+ for line in s.split('\n') {
+ let line = line.escape_control();
+ let mut width = 0;
+ let mut start = 0;
+ for (idx, ch) in line.char_indices() {
+ let w = ch.width().unwrap_or(0); // None for control chars which should not happen
+ if width + w > preview_width.into() {
+ lines.push(line[start..idx].to_owned());
+ start = idx;
+ width = w;
+ } else {
+ width += w;
+ }
+ }
+ if width != 0 {
+ lines.push(line[start..].to_owned());
+ }
+ }
+ lines.join("\n")
+ };
+
+ match compactness {
+ Compactness::Full => Paragraph::new(command).block(
+ Block::default()
+ .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = chunk_width - 2)),
+ ),
+ _ => Paragraph::new(command).style(Style::from_crossterm(style_annotation())),
+ }
+ }
+}
+
+/// The writer used for terminal output - either stdout or /dev/tty
+enum TerminalWriter {
+ Stdout(std::io::Stdout),
+ #[cfg(unix)]
+ Tty(std::fs::File),
+}
+
+impl TerminalWriter {
+ fn new() -> std::io::Result<Self> {
+ let stdout = stdout();
+ if stdout.is_terminal() {
+ return Ok(Self::Stdout(stdout));
+ }
+
+ // If stdout is not a terminal (e.g., captured by command substitution),
+ // fall back to /dev/tty so the TUI can still render.
+ // This allows usage like: VAR=$(atuin search -i)
+ #[cfg(unix)]
+ {
+ Ok(Self::Tty(
+ std::fs::File::options()
+ .read(true)
+ .write(true)
+ .open("/dev/tty")?,
+ ))
+ }
+ }
+}
+
+impl Write for TerminalWriter {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ match self {
+ Self::Stdout(stdout) => stdout.write(buf),
+ #[cfg(unix)]
+ Self::Tty(file) => file.write(buf),
+ }
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ match self {
+ Self::Stdout(stdout) => stdout.flush(),
+ #[cfg(unix)]
+ Self::Tty(file) => file.flush(),
+ }
+ }
+}
+
+/// Screen state captured from atuin pty-proxy's screen server.
+#[cfg(unix)]
+struct SavedScreen {
+ rows: u16,
+ cols: u16,
+ cursor_row: u16,
+ cursor_col: u16,
+ /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.
+ rows_data: Vec<Vec<u8>>,
+}
+
+/// Connect to atuin pty-proxy's Unix socket and fetch the current screen state.
+///
+/// The wire format is:
+/// ```text
+/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
+/// [row_0_len: u32 BE][row_0_bytes...]
+/// [row_1_len: u32 BE][row_1_bytes...]
+/// ...
+/// ```
+#[cfg(unix)]
+fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {
+ use std::os::unix::net::UnixStream;
+
+ let mut stream = UnixStream::connect(socket_path).ok()?;
+ stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
+
+ let mut data = Vec::new();
+ stream.read_to_end(&mut data).ok()?;
+
+ if data.len() < 8 {
+ return None;
+ }
+
+ let rows = u16::from_be_bytes([data[0], data[1]]);
+ let cols = u16::from_be_bytes([data[2], data[3]]);
+ let cursor_row = u16::from_be_bytes([data[4], data[5]]);
+ let cursor_col = u16::from_be_bytes([data[6], data[7]]);
+
+ // Parse length-prefixed rows
+ let mut rows_data = Vec::with_capacity(rows as usize);
+ let mut offset = 8;
+ while offset + 4 <= data.len() {
+ let row_len = u32::from_be_bytes([
+ data[offset],
+ data[offset + 1],
+ data[offset + 2],
+ data[offset + 3],
+ ]) as usize;
+ offset += 4;
+ if offset + row_len > data.len() {
+ break;
+ }
+ rows_data.push(data[offset..offset + row_len].to_vec());
+ offset += row_len;
+ }
+
+ Some(SavedScreen {
+ rows,
+ cols,
+ cursor_row,
+ cursor_col,
+ rows_data,
+ })
+}
+
+/// Restore the screen area that was covered by the popup.
+///
+/// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy
+/// directly to stdout, which correctly handles wide characters, colors, and
+/// all text attributes without needing a client-side vt100 parser.
+#[cfg(unix)]
+fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) {
+ use ratatui::crossterm::cursor::MoveTo;
+
+ let mut stdout = stdout();
+
+ for dy in 0..popup_rect.height {
+ let target_row = popup_rect.y + dy;
+ let source_row = (target_row + scroll_offset) as usize;
+
+ // Clear only the popup region. The server-side rows_formatted() skips
+ // default cells (spaces with default attributes) using cursor jumps, so
+ // any popup content at those positions would remain if not cleared
+ // beforehand. We write `popup_rect.width` spaces instead of
+ // ClearType::CurrentLine so that only the popup area is cleared, not
+ // the entire terminal line.
+ drop(execute!(
+ stdout,
+ MoveTo(popup_rect.x, target_row),
+ crossterm::style::SetAttribute(crossterm::style::Attribute::Reset),
+ ));
+ drop(write!(
+ stdout,
+ "{:width$}",
+ "",
+ width = popup_rect.width as usize
+ ));
+ drop(execute!(stdout, MoveTo(popup_rect.x, target_row)));
+
+ if let Some(row_bytes) = saved.rows_data.get(source_row) {
+ drop(stdout.write_all(row_bytes));
+ }
+ }
+
+ drop(execute!(
+ stdout,
+ MoveTo(
+ saved.cursor_col,
+ saved.cursor_row.saturating_sub(scroll_offset)
+ )
+ ));
+ drop(stdout.flush());
+}
+
+struct Stdout {
+ writer: TerminalWriter,
+ inline_mode: bool,
+ no_mouse: bool,
+}
+
+impl Stdout {
+ pub(crate) fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result<Self> {
+ terminal::enable_raw_mode()?;
+
+ let mut writer = TerminalWriter::new()?;
+
+ if !inline_mode {
+ execute!(writer, terminal::EnterAlternateScreen)?;
+ }
+
+ if !no_mouse {
+ execute!(writer, event::EnableMouseCapture)?;
+ }
+
+ execute!(writer, event::EnableBracketedPaste)?;
+
+ #[cfg(not(target_os = "windows"))]
+ execute!(
+ writer,
+ PushKeyboardEnhancementFlags(
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
+ ),
+ )?;
+
+ Ok(Self {
+ writer,
+ inline_mode,
+ no_mouse,
+ })
+ }
+}
+
+impl Drop for Stdout {
+ fn drop(&mut self) {
+ #[cfg(not(target_os = "windows"))]
+ if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) {
+ tracing::error!(?e, "Failed to pop keyboard enhancement flags");
+ }
+
+ if !self.inline_mode
+ && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen)
+ {
+ tracing::error!(?e, "Failed to leave alt screen mode");
+ }
+
+ if !self.no_mouse
+ && let Err(e) = execute!(self.writer, event::DisableMouseCapture)
+ {
+ tracing::error!(?e, "Failed to disable mouse capture");
+ }
+
+ if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) {
+ tracing::error!(?e, "Failed to disable bracketed paste");
+ }
+
+ if let Err(e) = terminal::disable_raw_mode() {
+ tracing::error!(?e, "Failed to disable raw mode");
+ }
+ }
+}
+
+impl Write for Stdout {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ self.writer.write(buf)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.writer.flush()
+ }
+}
+
+// this is a big blob of horrible! clean it up!
+/// Compute the popup position and any scroll offset needed to make room.
+///
+/// Given the cursor row, terminal dimensions, and desired popup height,
+/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number
+/// of lines the caller should scroll the terminal up before rendering.
+///
+/// This function performs no I/O — it is a pure computation.
+#[cfg(unix)]
+fn compute_popup_placement(
+ cursor_row: u16,
+ term_rows: u16,
+ term_cols: u16,
+ inline_height: u16,
+) -> (Rect, u16) {
+ let popup_w = term_cols;
+ let popup_h = inline_height.min(term_rows);
+ let space_below = term_rows.saturating_sub(cursor_row);
+
+ let (popup_y, scroll) = if popup_h <= space_below {
+ // Fits below cursor
+ (cursor_row, 0u16)
+ } else if cursor_row >= term_rows / 2 {
+ // Bottom half — render above cursor (overlay on existing text)
+ (cursor_row.saturating_sub(popup_h), 0u16)
+ } else {
+ // Top half, not enough space — scroll terminal to make room
+ let scroll = popup_h.saturating_sub(space_below);
+ let popup_y = cursor_row.saturating_sub(scroll);
+ (popup_y, scroll)
+ };
+
+ (Rect::new(0, popup_y, popup_w, popup_h), scroll)
+}
+
+// for now, it works. But it'd be great if it were more easily readable, and
+// modular. I'd like to add some more stats and stuff at some point
+#[expect(clippy::too_many_lines, clippy::cognitive_complexity)]
+pub(crate) async fn history(
+ query: &[String],
+ settings: &Settings,
+ mut db: ClientSqlite,
+ history_store: &HistoryStore,
+) -> Result<String> {
+ let inline_height = if settings.shell_up_key_binding {
+ settings
+ .inline_height_shell_up_key_binding
+ .unwrap_or(settings.inline_height)
+ } else {
+ settings.inline_height
+ };
+
+ // Use fullscreen mode if the inline height doesn't fit in the terminal,
+ // this will preserve the scroll position upon exit.
+ // Also force fullscreen when stdout isn't a terminal (e.g., command substitution
+ // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force
+ // fullscreen mode (inline mode won't work as it requires cursor position queries
+ // that don't work when stdout is captured).
+ let inline_height = if !stdout().is_terminal() {
+ 0
+ } else if let Ok(size) = terminal::size()
+ && inline_height >= size.1
+ {
+ 0
+ } else {
+ inline_height
+ };
+
+ // Popup mode: if running under atuin pty-proxy and inline mode is requested,
+ // fetch the screen state and render as a centered overlay.
+ #[cfg(unix)]
+ let (saved_screen, popup_rect, popup_scroll_offset) = {
+ let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET")
+ .or_else(|_| std::env::var("ATUIN_HEX_SOCKET"))
+ .ok();
+ if let Some(ref path) = socket_path
+ && inline_height > 0
+ {
+ let saved = fetch_screen_state(path);
+ if let Some(ref s) = saved {
+ let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows));
+ let (popup_rect, scroll) =
+ compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height);
+
+ // Scroll terminal content up to make room if needed
+ if scroll > 0 {
+ use ratatui::crossterm::cursor::MoveTo;
+ let mut stdout = stdout();
+ drop(execute!(stdout, MoveTo(0, term_rows - 1)));
+ for _ in 0..scroll {
+ drop(writeln!(stdout));
+ }
+ drop(stdout.flush());
+ }
+
+ (saved, popup_rect, scroll)
+ } else {
+ (None, Rect::default(), 0u16)
+ }
+ } else {
+ (None, Rect::default(), 0u16)
+ }
+ };
+
+ let popup_mode = saved_screen.is_some();
+
+ let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?;
+
+ // In popup mode, clear the popup region on the physical terminal before
+ // ratatui takes over. Ratatui's diff-based rendering compares against an
+ // initially-empty buffer, so cells that remain "empty" (spaces with default
+ // style) won't be written — leaving underlying terminal text visible.
+ // By pre-clearing with spaces, those cells are already correct on screen.
+ if popup_mode {
+ use ratatui::crossterm::cursor::MoveTo;
+ let mut raw_stdout = std::io::stdout();
+ // Queue all commands without flushing so the terminal receives them
+ // as a single write — no intermediate cursor positions are visible.
+ drop(queue!(
+ raw_stdout,
+ crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
+ ));
+ for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
+ drop(queue!(raw_stdout, MoveTo(popup_rect.x, row)));
+ drop(write!(
+ raw_stdout,
+ "{:width$}",
+ "",
+ width = popup_rect.width as usize
+ ));
+ }
+ drop(raw_stdout.flush());
+ }
+
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::with_options(
+ backend,
+ TerminalOptions {
+ viewport: if popup_mode {
+ Viewport::Fixed(popup_rect)
+ } else if inline_height > 0 {
+ Viewport::Inline(inline_height)
+ } else {
+ Viewport::Fullscreen
+ },
+ },
+ )?;
+
+ let original_query = query.join(" ");
+
+ // Check if this is a command chaining scenario
+ let is_command_chaining = if settings.command_chaining {
+ let trimmed = original_query.trim_end();
+ trimmed.ends_with("&&") || trimmed.ends_with('|')
+ } else {
+ false
+ };
+
+ // For command chaining, start with empty input to allow searching for new commands
+ let search_input = if is_command_chaining {
+ String::new()
+ } else {
+ original_query.clone()
+ };
+
+ let mut input = Cursor::from(search_input);
+ // Put the cursor at the end of the query by default
+ input.end();
+
+ let initial_context = current_context().await?;
+
+ let history_count = db.history_count(false).await?;
+ let search_mode = if settings.shell_up_key_binding {
+ settings
+ .search_mode_shell_up_key_binding
+ .unwrap_or(settings.search_mode)
+ } else {
+ settings.search_mode
+ };
+ let default_filter_mode = settings
+ .filter_mode_shell_up_key_binding
+ .filter(|_| settings.shell_up_key_binding)
+ .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));
+ let mut app = State {
+ history_count,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode,
+ tab_index: 0,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::from_settings(settings),
+ search: SearchState {
+ input,
+ filter_mode: default_filter_mode,
+ context: initial_context.clone(),
+ custom_context: None,
+ },
+ engine: engines::engine(search_mode, settings),
+ results_len: 0,
+ accept: false,
+ keymap_mode: match settings.keymap_mode {
+ KeymapMode::Auto => KeymapMode::Emacs,
+ value => value,
+ },
+ current_cursor: None,
+ now: if settings.prefers_reduced_motion {
+ let now = OffsetDateTime::now_utc();
+ Box::new(move || now)
+ } else {
+ Box::new(OffsetDateTime::now_utc)
+ },
+ prefix: false,
+ pending_vim_key: None,
+ original_input_empty: original_query.is_empty(),
+ };
+
+ app.initialize_keymap_cursor(settings);
+
+ let mut results = app.query_results(&mut db, settings.smart_sort).await?;
+
+ if inline_height > 0 && !popup_mode {
+ terminal.clear()?;
+ }
+
+ let mut stats: Option<HistoryStats> = None;
+ let mut inspecting: Option<History> = None;
+ let accept;
+ let result = 'render: loop {
+ terminal.draw(|f| {
+ app.draw(
+ f,
+ &results,
+ stats.clone(),
+ inspecting.as_ref(),
+ settings,
+ popup_mode,
+ );
+ })?;
+
+ let initial_input = app.search.input.as_str().to_owned();
+ let initial_filter_mode = app.search.filter_mode;
+ let initial_search_mode = app.search_mode;
+ let initial_custom_context = app.search.custom_context.clone();
+
+ let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));
+
+ tokio::select! {
+ event_ready = event_ready => {
+ if event_ready?? {
+ loop {
+ match app.handle_input(settings, &event::read()?) {
+ InputAction::Continue => {},
+ InputAction::Delete(index) => {
+ if results.is_empty() {
+ break;
+ }
+ app.results_len -= 1;
+ let selected = app.results_state.selected();
+ if selected == app.results_len {
+ app.inspecting_state.reset();
+ app.results_state.select(selected - 1);
+ }
+
+ let entry = results.remove(index);
+
+ let ids = history_store.delete_entries([entry]).await?;
+ history_store.incremental_build(&db, &ids).await?;
+
+ app.tab_index = 0;
+ },
+ InputAction::DeleteAllMatching(index) => {
+ if results.is_empty() {
+ break;
+ }
+
+ let command = results[index].command.clone();
+
+ // Remove matching entries from the visible results
+ results.retain(|e| e.command != command);
+
+ // Query the DB for ALL entries with this command and delete them
+ let all_matching = db.query_history(
+ &format!(
+ "select * from history where command = '{}' and deleted_at is null",
+ command.replace('\'', "''")
+ )
+ ).await?;
+
+ let ids = history_store.delete_entries(all_matching).await?;
+ history_store.incremental_build(&db, &ids).await?;
+
+ app.results_len = results.len();
+ app.results_state = ListState::default();
+ app.inspecting_state.reset();
+ app.tab_index = 0;
+ },
+ InputAction::SwitchContext(index) => {
+ if let Some(index) = index && let Some(entry) = results.get(index) {
+ app.search.custom_context = Some(entry.id.clone());
+ app.search.context = Context::from_history(entry);
+ app.search.filter_mode = FilterMode::Session;
+ app.search.input = Cursor::from(String::new());
+ app.results_state = ListState::default();
+ } else {
+ app.search.custom_context = None;
+ app.search.context = initial_context.clone();
+ app.search.filter_mode = default_filter_mode;
+ }
+ },
+ InputAction::Redraw => {
+ if !popup_mode {
+ terminal.clear()?;
+ }
+ terminal.draw(|f| {
+ app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, popup_mode);
+ })?;
+ },
+ r => {
+ accept = app.accept;
+ break 'render r;
+ },
+ }
+ if !event::poll(Duration::ZERO)? {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if initial_input != app.search.input.as_str()
+ || initial_filter_mode != app.search.filter_mode
+ || initial_search_mode != app.search_mode
+ || initial_custom_context != app.search.custom_context
+ {
+ results = app.query_results(&mut db, settings.smart_sort).await?;
+ }
+
+ // In custom context mode, when no filter is applied, highlight the entry which was used
+ // to enter the context when changing modes. This helps to find your way around.
+ if app.search.custom_context.is_some()
+ && app.search.input.as_str().is_empty()
+ && (initial_custom_context != app.search.custom_context
+ || initial_filter_mode != app.search.filter_mode)
+ && let Some(history_id) = app.search.custom_context.clone()
+ && let Some(pos) = results.iter().position(|entry| entry.id == history_id)
+ {
+ app.results_state.select(pos);
+ }
+
+ let inspecting_id = app.inspecting_state.clone().current;
+ // If inspecting ID is not the current inspecting History, update it.
+ match inspecting_id {
+ Some(inspecting_id) => {
+ if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
+ inspecting = db.load(inspecting_id.0.as_str()).await?;
+ }
+ }
+ _ => {
+ inspecting = None;
+ }
+ }
+
+ stats = if app.tab_index == 0 {
+ None
+ } else if !results.is_empty() {
+ // If we have stats, then we can indicate next available IDs. This avoids passing
+ // around a database object, or a full stats object.
+ let selected = match inspecting.clone() {
+ Some(insp) => insp,
+ None => results[app.results_state.selected()].clone(),
+ };
+ let stats = db.stats(&selected).await?;
+ app.inspecting_state.current = Some(selected.id);
+ app.inspecting_state.previous = match stats.previous.clone() {
+ Some(p) => Some(p.id),
+ _ => None,
+ };
+ app.inspecting_state.next = match stats.next.clone() {
+ Some(p) => Some(p.id),
+ _ => None,
+ };
+ Some(stats)
+ } else {
+ None
+ };
+ };
+
+ app.finalize_keymap_cursor(settings);
+
+ if popup_mode {
+ // In popup mode, restore the screen area that was covered by the popup.
+ // This must happen before Stdout is dropped (which disables raw mode).
+ #[cfg(unix)]
+ if let Some(ref saved) = saved_screen {
+ restore_popup_area(saved, popup_rect, popup_scroll_offset);
+ }
+ } else if inline_height > 0 {
+ terminal.clear()?;
+ }
+
+ let accept = accept
+ && matches!(
+ Shell::from_env(),
+ Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell
+ );
+
+ let accept_prefix = "__atuin_accept__:";
+
+ match result {
+ InputAction::AcceptInspecting => {
+ match inspecting {
+ Some(result) => {
+ let mut command = result.command;
+
+ if accept {
+ command = String::from(accept_prefix) + &command;
+ }
+
+ // index is in bounds so we return that entry
+ Ok(command)
+ }
+ None => Ok(String::new()),
+ }
+ }
+ InputAction::Accept(index) if index < results.len() => {
+ let mut command = results.swap_remove(index).command;
+
+ if is_command_chaining {
+ command = format!("{} {}", original_query.trim_end(), command);
+ } else if accept {
+ command = String::from(accept_prefix) + &command;
+ }
+
+ // index is in bounds so we return that entry
+ Ok(command)
+ }
+ InputAction::ReturnOriginal => Ok(String::new()),
+ InputAction::Copy(index) => {
+ let cmd = results.swap_remove(index).command;
+ set_clipboard(cmd);
+ Ok(String::new())
+ }
+ InputAction::ReturnQuery | InputAction::Accept(_) => {
+ // Either:
+ // * index == RETURN_QUERY, in which case we should return the input
+ // * out of bounds -> usually implies no selected entry so we return the input
+ Ok(app.search.input.into_inner())
+ }
+ InputAction::Continue
+ | InputAction::Redraw
+ | InputAction::Delete(_)
+ | InputAction::DeleteAllMatching(_)
+ | InputAction::SwitchContext(_) => {
+ unreachable!("should have been handled!")
+ }
+ }
+}
+
+// cli-clipboard only works on Windows, Mac, and Linux.
+
+#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
+fn set_clipboard(s: String) {
+ let mut ctx = arboard::Clipboard::new().unwrap();
+ ctx.set_text(s).unwrap();
+ // Use the clipboard context to make sure it is saved
+ ctx.get_text().unwrap();
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_client::database::Context;
+ use crate::atuin_client::history::History;
+ use crate::atuin_client::settings::{
+ FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings,
+ };
+ use time::OffsetDateTime;
+
+ use crate::command::client::search::engines::{self, SearchState};
+ use crate::command::client::search::history_list::ListState;
+
+ use super::{Compactness, InspectingState, KeymapSet, State};
+
+ #[test]
+ #[expect(clippy::too_many_lines)]
+ fn calc_preview_height_test() {
+ let settings_preview_auto = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Auto,
+ },
+ show_preview: true,
+ ..Settings::new().unwrap()
+ };
+
+ let settings_preview_auto_h2 = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Auto,
+ },
+ show_preview: true,
+ max_preview_height: 2,
+ ..Settings::new().unwrap()
+ };
+
+ let settings_preview_h4 = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Static,
+ },
+ show_preview: true,
+ max_preview_height: 4,
+ ..Settings::new().unwrap()
+ };
+
+ let settings_preview_fixed = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Fixed,
+ },
+ show_preview: true,
+ max_preview_height: 15,
+ ..Settings::new().unwrap()
+ };
+
+ let cmd_60: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done")
+ .cwd("/")
+ .build()
+ .into();
+
+ let cmd_124: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'")
+ .cwd("/")
+ .build()
+ .into();
+
+ let cmd_200: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'")
+ .cwd("/")
+ .build()
+ .into();
+
+ let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200];
+
+ // the selected command does not require a preview
+ let no_preview = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 0_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires 2 lines
+ let preview_h2 = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires 3 lines
+ let preview_h3 = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 2_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width)
+ let preview_one_line = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 0_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 66,
+ );
+ // the selected command requires 3 lines, but we have a max preview height limit of 2
+ let preview_limit_at_2 = State::calc_preview_height(
+ &settings_preview_auto_h2,
+ &results,
+ 2_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the longest command requires 3 lines
+ let preview_static_h3 = State::calc_preview_height(
+ &settings_preview_h4,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the longest command requires 10 lines, but we have a max preview height limit of 4
+ let preview_static_limit_at_4 = State::calc_preview_height(
+ &settings_preview_h4,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 20,
+ );
+ // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy
+ let settings_preview_fixed = State::calc_preview_height(
+ &settings_preview_fixed,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 20,
+ );
+
+ assert_eq!(no_preview, 1);
+ // 1 * 2 is the space for the border
+ let border_space = 2;
+ assert_eq!(preview_h2, 2 + border_space);
+ assert_eq!(preview_h3, 3 + border_space);
+ assert_eq!(preview_one_line, 1 + border_space);
+ assert_eq!(preview_limit_at_2, 2 + border_space);
+ assert_eq!(preview_static_h3, 3 + border_space);
+ assert_eq!(preview_static_limit_at_4, 4 + border_space);
+ assert_eq!(settings_preview_fixed, 15 + border_space);
+ }
+
+ // Test when there's no results, scrolling up or down doesn't underflow
+ #[test]
+ fn state_scroll_up_underflow() {
+ let settings = Settings::new().unwrap();
+ let mut state = State {
+ history_count: 0,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 0,
+ accept: false,
+ keymap_mode: KeymapMode::Auto,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Directory,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.scroll_up(1);
+ state.scroll_down(1);
+ }
+
+ #[test]
+ fn test_accept_keybindings() {
+ use crate::atuin_client::settings::Keys;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let mut settings = Settings::new().unwrap();
+ settings.keys = Keys {
+ scroll_exits: true,
+ exit_past_line_start: false,
+ accept_past_line_end: true,
+ accept_past_line_start: false,
+ accept_with_backspace: false,
+ prefix: "a".to_string(),
+ };
+
+ let mut state = State {
+ history_count: 1,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 1,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &tab_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Tab should always accept"
+ );
+
+ // Test left arrow with accept_past_line_start disabled (should continue)
+ let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &left_event);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Left arrow should continue when disabled"
+ );
+
+ // Test left arrow with accept_past_line_start enabled (should accept at start of line)
+ settings.keys.accept_past_line_start = true;
+ state.keymaps = KeymapSet::defaults(&settings);
+ let result = state.handle_key_input(&settings, &left_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Left arrow should accept at start of line when enabled"
+ );
+ settings.keys.accept_past_line_start = false;
+ state.keymaps = KeymapSet::defaults(&settings);
+
+ let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &backspace_event);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Backspace should continue when disabled"
+ );
+
+ settings.keys.accept_with_backspace = true;
+ state.keymaps = KeymapSet::defaults(&settings);
+ let result = state.handle_key_input(&settings, &backspace_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Backspace should accept at start of line when enabled"
+ );
+
+ state.search.input.insert('t');
+ state.search.input.insert('e');
+ state.search.input.insert('s');
+ state.search.input.insert('t');
+ state.search.input.end();
+
+ let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &right_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Right arrow should accept at end of line when enabled"
+ );
+
+ settings.keys.accept_past_line_start = true;
+ state.keymaps = KeymapSet::defaults(&settings);
+ let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &left_event);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Left arrow should continue and end of line, even when enabled"
+ );
+ settings.keys.accept_past_line_start = false;
+ state.keymaps = KeymapSet::defaults(&settings);
+
+ settings.keys.accept_with_backspace = true;
+ state.keymaps = KeymapSet::defaults(&settings);
+ let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &backspace_event);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Backspace should continue at end of line, even when enabled"
+ );
+ settings.keys.accept_with_backspace = false;
+ state.keymaps = KeymapSet::defaults(&settings);
+ }
+
+ #[test]
+ fn test_vim_gg_multikey_sequence() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::new().unwrap();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ // Start in the middle of the list
+ state.results_state.select(50);
+
+ // First 'g' should set pending state
+ let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, Some('g'));
+ assert_eq!(state.results_state.selected(), 50); // Position unchanged
+
+ // Second 'g' should jump to end (visual top in non-inverted mode)
+ let result = state.handle_key_input(&settings, &g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+ assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top)
+ }
+
+ #[test]
+ fn test_vim_g_key_clears_on_other_input() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::new().unwrap();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Press 'g' to set pending state
+ let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
+ state.handle_key_input(&settings, &g_event);
+ assert_eq!(state.pending_vim_key, Some('g'));
+
+ // Press 'j' - should clear pending state
+ let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
+ state.handle_key_input(&settings, &j_event);
+ assert_eq!(state.pending_vim_key, None);
+ }
+
+ #[test]
+ fn test_vim_big_g_jump_to_bottom() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::new().unwrap();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // 'G' should jump to visual bottom (index 0 in non-inverted mode)
+ let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &big_g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ #[expect(clippy::similar_names)]
+ fn test_vim_ctrl_u_d_half_page_scroll() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::new().unwrap();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Ctrl+d should return Continue and clear pending key
+ // (scroll amount depends on max_entries which is 0 in tests)
+ state.pending_vim_key = Some('g');
+ let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_d_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+
+ // Ctrl+u should return Continue and clear pending key
+ state.pending_vim_key = Some('g');
+ let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_u_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+ }
+
+ #[test]
+ #[expect(clippy::similar_names)]
+ fn test_vim_ctrl_f_b_full_page_scroll() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::new().unwrap();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Ctrl+f should return Continue and clear pending key
+ // (scroll amount depends on max_entries which is 0 in tests)
+ state.pending_vim_key = Some('g');
+ let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_f_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+
+ // Ctrl+b should return Continue and clear pending key
+ state.pending_vim_key = Some('g');
+ let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_b_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+ }
+
+ // -----------------------------------------------------------------------
+ // Executor tests (execute_action)
+ // -----------------------------------------------------------------------
+
+ /// Helper to build a State for executor tests.
+ fn make_executor_state(results_len: usize, selected: usize) -> State {
+ let settings = Settings::new().unwrap();
+ let mut state = State {
+ history_count: results_len as i64,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+ state.results_state.select(selected);
+ state
+ }
+
+ #[test]
+ fn execute_select_next_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::SelectNext, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: SelectNext = scroll_down = selected - 1
+ assert_eq!(state.results_state.selected(), 49);
+ }
+
+ #[test]
+ fn execute_select_next_with_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let mut settings = Settings::new().unwrap();
+ settings.invert = true;
+ let result = state.execute_action(&Action::SelectNext, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Inverted: SelectNext = scroll_up = selected + 1
+ assert_eq!(state.results_state.selected(), 51);
+ }
+
+ #[test]
+ fn execute_select_previous_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::SelectPrevious, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: SelectPrevious = scroll_up = selected + 1
+ assert_eq!(state.results_state.selected(), 51);
+ }
+
+ #[test]
+ fn execute_vim_enter_normal() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::VimEnterNormal, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.keymap_mode, KeymapMode::VimNormal);
+ }
+
+ #[test]
+ fn execute_vim_enter_insert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ state.keymap_mode = KeymapMode::VimNormal;
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::VimEnterInsert, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
+ }
+
+ #[test]
+ fn execute_accept_sets_accept_flag() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let mut settings = Settings::new().unwrap();
+ settings.enter_accept = true;
+ let result = state.execute_action(&Action::Accept, &settings);
+ assert!(matches!(result, super::InputAction::Accept(5)));
+ assert!(state.accept);
+ }
+
+ #[test]
+ fn execute_return_selection_does_not_set_accept() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::ReturnSelection, &settings);
+ assert!(matches!(result, super::InputAction::Accept(5)));
+ assert!(!state.accept);
+ }
+
+ #[test]
+ fn execute_accept_nth() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::AcceptNth(3), &settings);
+ assert!(matches!(result, super::InputAction::Accept(8)));
+ }
+
+ #[test]
+ fn execute_scroll_to_top_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::ScrollToTop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: visual top = highest index
+ assert_eq!(state.results_state.selected(), 99);
+ }
+
+ #[test]
+ fn execute_scroll_to_top_with_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let mut settings = Settings::new().unwrap();
+ settings.invert = true;
+ let result = state.execute_action(&Action::ScrollToTop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Inverted: visual top = index 0
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ fn execute_scroll_to_bottom_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::ScrollToBottom, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: visual bottom = index 0
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ fn execute_toggle_tab() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+ assert_eq!(state.tab_index, 0);
+ state.execute_action(&Action::ToggleTab, &settings);
+ assert_eq!(state.tab_index, 1);
+ state.execute_action(&Action::ToggleTab, &settings);
+ assert_eq!(state.tab_index, 0);
+ }
+
+ #[test]
+ fn execute_enter_prefix_mode() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+ assert!(!state.prefix);
+ state.execute_action(&Action::EnterPrefixMode, &settings);
+ assert!(state.prefix);
+ }
+
+ #[test]
+ fn execute_exit_returns_based_on_exit_mode() {
+ use crate::atuin_client::settings::ExitMode;
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let mut settings = Settings::new().unwrap();
+
+ settings.exit_mode = ExitMode::ReturnOriginal;
+ let result = state.execute_action(&Action::Exit, &settings);
+ assert!(matches!(result, super::InputAction::ReturnOriginal));
+
+ settings.exit_mode = ExitMode::ReturnQuery;
+ let result = state.execute_action(&Action::Exit, &settings);
+ assert!(matches!(result, super::InputAction::ReturnQuery));
+ }
+
+ #[test]
+ fn execute_return_original() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::ReturnOriginal, &settings);
+ assert!(matches!(result, super::InputAction::ReturnOriginal));
+ }
+
+ #[test]
+ fn execute_copy() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::Copy, &settings);
+ assert!(matches!(result, super::InputAction::Copy(7)));
+ }
+
+ #[test]
+ fn execute_delete() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::Delete, &settings);
+ assert!(matches!(result, super::InputAction::Delete(7)));
+ }
+
+ #[test]
+ fn execute_switch_context() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::SwitchContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));
+ }
+
+ #[test]
+ fn execute_clear_context() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::ClearContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(None)));
+ }
+
+ #[test]
+ fn execute_noop() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::Noop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.results_state.selected(), 50);
+ }
+
+ #[test]
+ fn execute_accept_in_inspector_tab() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ state.tab_index = 1;
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::Accept, &settings);
+ assert!(matches!(result, super::InputAction::AcceptInspecting));
+ }
+
+ #[test]
+ fn execute_cycle_search_mode() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+ let original_mode = state.search_mode;
+ let result = state.execute_action(&Action::CycleSearchMode, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert!(state.switched_search_mode);
+ assert_ne!(state.search_mode, original_mode);
+ }
+
+ #[test]
+ fn execute_vim_search_insert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ state.search.input.insert('h');
+ state.search.input.insert('i');
+ state.keymap_mode = KeymapMode::VimNormal;
+ let settings = Settings::new().unwrap();
+ let result = state.execute_action(&Action::VimSearchInsert, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Should clear input and switch to insert mode
+ assert_eq!(state.search.input.as_str(), "");
+ assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
+ }
+
+ #[test]
+ fn execute_cursor_movement() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+
+ // Insert some text
+ state.search.input.insert('h');
+ state.search.input.insert('e');
+ state.search.input.insert('l');
+ state.search.input.insert('l');
+ state.search.input.insert('o');
+ // cursor is at end (position 5)
+
+ // CursorLeft
+ state.execute_action(&Action::CursorLeft, &settings);
+ assert_eq!(state.search.input.position(), 4);
+
+ // CursorStart
+ state.execute_action(&Action::CursorStart, &settings);
+ assert_eq!(state.search.input.position(), 0);
+
+ // CursorEnd
+ state.execute_action(&Action::CursorEnd, &settings);
+ assert_eq!(state.search.input.position(), 5);
+
+ // CursorRight at end does nothing
+ state.execute_action(&Action::CursorRight, &settings);
+ assert_eq!(state.search.input.position(), 5);
+ }
+
+ #[test]
+ fn execute_editing() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::new().unwrap();
+
+ // Insert "hello"
+ state.search.input.insert('h');
+ state.search.input.insert('e');
+ state.search.input.insert('l');
+ state.search.input.insert('l');
+ state.search.input.insert('o');
+
+ // DeleteCharBefore (backspace)
+ state.execute_action(&Action::DeleteCharBefore, &settings);
+ assert_eq!(state.search.input.as_str(), "hell");
+
+ // ClearLine
+ state.execute_action(&Action::ClearLine, &settings);
+ assert_eq!(state.search.input.as_str(), "");
+ }
+
+ #[test]
+ fn keymap_config_return_query() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use std::collections::HashMap;
+
+ let mut settings = Settings::new().unwrap();
+ // Configure tab to return-query
+ settings.keymap.emacs = HashMap::from([(
+ "tab".to_string(),
+ KeyBindingConfig::Simple("return-query".to_string()),
+ )]);
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::from_settings(&settings),
+ search: SearchState {
+ input: "test query".to_string().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &tab_event);
+ assert!(
+ matches!(result, super::InputAction::ReturnQuery),
+ "Tab configured as return-query should return InputAction::ReturnQuery"
+ );
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/actions.rs b/crates/client/src/command/client/search/keybindings/actions.rs
new file mode 100644
index 00000000..2842d618
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/actions.rs
@@ -0,0 +1,322 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// All possible actions that can be triggered by a keybinding.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum Action {
+ // Cursor movement
+ CursorLeft,
+ CursorRight,
+ CursorWordLeft,
+ CursorWordRight,
+ CursorWordEnd,
+ CursorStart,
+ CursorEnd,
+
+ // Editing
+ DeleteCharBefore,
+ DeleteCharAfter,
+ DeleteWordBefore,
+ DeleteWordAfter,
+ DeleteToWordBoundary,
+ ClearLine,
+ ClearToStart,
+ ClearToEnd,
+
+ // List navigation
+ SelectNext,
+ SelectPrevious,
+ ScrollHalfPageUp,
+ ScrollHalfPageDown,
+ ScrollPageUp,
+ ScrollPageDown,
+ ScrollToTop,
+ ScrollToBottom,
+ ScrollToScreenTop,
+ ScrollToScreenMiddle,
+ ScrollToScreenBottom,
+
+ // Commands — accept selection and execute immediately
+ Accept,
+ AcceptNth(u8),
+ // Commands — return selection to command line without executing
+ ReturnSelection,
+ ReturnSelectionNth(u8),
+ // Commands — other
+ Copy,
+ Delete,
+ DeleteAll,
+ ReturnOriginal,
+ ReturnQuery,
+ Exit,
+ Redraw,
+ CycleFilterMode,
+ CycleSearchMode,
+ SwitchContext,
+ ClearContext,
+ ToggleTab,
+
+ // Mode changes
+ VimEnterNormal,
+ VimEnterInsert,
+ VimEnterInsertAfter,
+ VimEnterInsertAtStart,
+ VimEnterInsertAtEnd,
+ VimSearchInsert,
+ VimChangeToEnd,
+ EnterPrefixMode,
+
+ // Inspector
+ InspectPrevious,
+ InspectNext,
+
+ // Special
+ Noop,
+}
+
+impl Action {
+ /// Convert from a kebab-case string.
+ pub(crate) fn from_str(s: &str) -> Result<Self, String> {
+ // Handle accept-N and return-selection-N patterns
+ if let Some(rest) = s.strip_prefix("accept-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Self::AcceptNth(n));
+ }
+ if let Some(rest) = s.strip_prefix("return-selection-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Self::ReturnSelectionNth(n));
+ }
+
+ match s {
+ "cursor-left" => Ok(Self::CursorLeft),
+ "cursor-right" => Ok(Self::CursorRight),
+ "cursor-word-left" => Ok(Self::CursorWordLeft),
+ "cursor-word-right" => Ok(Self::CursorWordRight),
+ "cursor-word-end" => Ok(Self::CursorWordEnd),
+ "cursor-start" => Ok(Self::CursorStart),
+ "cursor-end" => Ok(Self::CursorEnd),
+
+ "delete-char-before" => Ok(Self::DeleteCharBefore),
+ "delete-char-after" => Ok(Self::DeleteCharAfter),
+ "delete-word-before" => Ok(Self::DeleteWordBefore),
+ "delete-word-after" => Ok(Self::DeleteWordAfter),
+ "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary),
+ "clear-line" => Ok(Self::ClearLine),
+ "clear-to-start" => Ok(Self::ClearToStart),
+ "clear-to-end" => Ok(Self::ClearToEnd),
+
+ "select-next" => Ok(Self::SelectNext),
+ "select-previous" => Ok(Self::SelectPrevious),
+ "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp),
+ "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown),
+ "scroll-page-up" => Ok(Self::ScrollPageUp),
+ "scroll-page-down" => Ok(Self::ScrollPageDown),
+ "scroll-to-top" => Ok(Self::ScrollToTop),
+ "scroll-to-bottom" => Ok(Self::ScrollToBottom),
+ "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop),
+ "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle),
+ "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom),
+
+ "accept" => Ok(Self::Accept),
+ "return-selection" => Ok(Self::ReturnSelection),
+ "copy" => Ok(Self::Copy),
+ "delete" => Ok(Self::Delete),
+ "delete-all" => Ok(Self::DeleteAll),
+ "return-original" => Ok(Self::ReturnOriginal),
+ "return-query" => Ok(Self::ReturnQuery),
+ "exit" => Ok(Self::Exit),
+ "redraw" => Ok(Self::Redraw),
+ "cycle-filter-mode" => Ok(Self::CycleFilterMode),
+ "cycle-search-mode" => Ok(Self::CycleSearchMode),
+ "switch-context" => Ok(Self::SwitchContext),
+ "clear-context" => Ok(Self::ClearContext),
+ "toggle-tab" => Ok(Self::ToggleTab),
+
+ "vim-enter-normal" => Ok(Self::VimEnterNormal),
+ "vim-enter-insert" => Ok(Self::VimEnterInsert),
+ "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter),
+ "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart),
+ "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd),
+ "vim-search-insert" => Ok(Self::VimSearchInsert),
+ "vim-change-to-end" => Ok(Self::VimChangeToEnd),
+ "enter-prefix-mode" => Ok(Self::EnterPrefixMode),
+
+ "inspect-previous" => Ok(Self::InspectPrevious),
+ "inspect-next" => Ok(Self::InspectNext),
+
+ "noop" => Ok(Self::Noop),
+
+ _ => Err(format!("unknown action: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub(crate) fn as_str(&self) -> String {
+ match self {
+ Self::CursorLeft => "cursor-left".to_string(),
+ Self::CursorRight => "cursor-right".to_string(),
+ Self::CursorWordLeft => "cursor-word-left".to_string(),
+ Self::CursorWordRight => "cursor-word-right".to_string(),
+ Self::CursorWordEnd => "cursor-word-end".to_string(),
+ Self::CursorStart => "cursor-start".to_string(),
+ Self::CursorEnd => "cursor-end".to_string(),
+
+ Self::DeleteCharBefore => "delete-char-before".to_string(),
+ Self::DeleteCharAfter => "delete-char-after".to_string(),
+ Self::DeleteWordBefore => "delete-word-before".to_string(),
+ Self::DeleteWordAfter => "delete-word-after".to_string(),
+ Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
+ Self::ClearLine => "clear-line".to_string(),
+ Self::ClearToStart => "clear-to-start".to_string(),
+ Self::ClearToEnd => "clear-to-end".to_string(),
+
+ Self::SelectNext => "select-next".to_string(),
+ Self::SelectPrevious => "select-previous".to_string(),
+ Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
+ Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
+ Self::ScrollPageUp => "scroll-page-up".to_string(),
+ Self::ScrollPageDown => "scroll-page-down".to_string(),
+ Self::ScrollToTop => "scroll-to-top".to_string(),
+ Self::ScrollToBottom => "scroll-to-bottom".to_string(),
+ Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
+ Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
+ Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
+
+ Self::Accept => "accept".to_string(),
+ Self::AcceptNth(n) => format!("accept-{n}"),
+ Self::ReturnSelection => "return-selection".to_string(),
+ Self::ReturnSelectionNth(n) => format!("return-selection-{n}"),
+ Self::Copy => "copy".to_string(),
+ Self::Delete => "delete".to_string(),
+ Self::DeleteAll => "delete-all".to_string(),
+ Self::ReturnOriginal => "return-original".to_string(),
+ Self::ReturnQuery => "return-query".to_string(),
+ Self::Exit => "exit".to_string(),
+ Self::Redraw => "redraw".to_string(),
+ Self::CycleFilterMode => "cycle-filter-mode".to_string(),
+ Self::CycleSearchMode => "cycle-search-mode".to_string(),
+ Self::SwitchContext => "switch-context".to_string(),
+ Self::ClearContext => "clear-context".to_string(),
+ Self::ToggleTab => "toggle-tab".to_string(),
+
+ Self::VimEnterNormal => "vim-enter-normal".to_string(),
+ Self::VimEnterInsert => "vim-enter-insert".to_string(),
+ Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
+ Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
+ Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
+ Self::VimSearchInsert => "vim-search-insert".to_string(),
+ Self::VimChangeToEnd => "vim-change-to-end".to_string(),
+ Self::EnterPrefixMode => "enter-prefix-mode".to_string(),
+
+ Self::InspectPrevious => "inspect-previous".to_string(),
+ Self::InspectNext => "inspect-next".to_string(),
+
+ Self::Noop => "noop".to_string(),
+ }
+ }
+}
+
+impl fmt::Display for Action {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl Serialize for Action {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.as_str())
+ }
+}
+
+impl<'de> Deserialize<'de> for Action {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ Self::from_str(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Action;
+
+ #[test]
+ fn parse_basic_actions() {
+ assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft);
+ assert_eq!(Action::from_str("accept").unwrap(), Action::Accept);
+ assert_eq!(Action::from_str("exit").unwrap(), Action::Exit);
+ assert_eq!(Action::from_str("noop").unwrap(), Action::Noop);
+ assert_eq!(
+ Action::from_str("vim-enter-normal").unwrap(),
+ Action::VimEnterNormal
+ );
+ }
+
+ #[test]
+ fn parse_accept_nth() {
+ assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1));
+ assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9));
+ }
+
+ #[test]
+ fn parse_return_selection() {
+ assert_eq!(
+ Action::from_str("return-selection").unwrap(),
+ Action::ReturnSelection
+ );
+ assert_eq!(
+ Action::from_str("return-selection-1").unwrap(),
+ Action::ReturnSelectionNth(1)
+ );
+ assert_eq!(
+ Action::from_str("return-selection-9").unwrap(),
+ Action::ReturnSelectionNth(9)
+ );
+ }
+
+ #[test]
+ fn parse_unknown_action() {
+ assert!(Action::from_str("unknown-action").is_err());
+ assert!(Action::from_str("accept-0").is_err());
+ assert!(Action::from_str("accept-10").is_err());
+ assert!(Action::from_str("return-selection-0").is_err());
+ assert!(Action::from_str("return-selection-10").is_err());
+ }
+
+ #[test]
+ fn round_trip() {
+ let actions = vec![
+ Action::CursorLeft,
+ Action::Accept,
+ Action::AcceptNth(5),
+ Action::ReturnSelection,
+ Action::ReturnSelectionNth(3),
+ Action::VimSearchInsert,
+ Action::ScrollToScreenMiddle,
+ ];
+ for action in actions {
+ let s = action.as_str();
+ let parsed = Action::from_str(&s).unwrap();
+ assert_eq!(action, parsed);
+ }
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let action = Action::CursorLeft;
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"cursor-left\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::CursorLeft);
+
+ let action = Action::AcceptNth(3);
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"accept-3\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::AcceptNth(3));
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/conditions.rs b/crates/client/src/command/client/search/keybindings/conditions.rs
new file mode 100644
index 00000000..fd993f2b
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/conditions.rs
@@ -0,0 +1,801 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// Atomic (leaf) conditions that can be evaluated against state.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum ConditionAtom {
+ CursorAtStart,
+ CursorAtEnd,
+ InputEmpty,
+ OriginalInputEmpty,
+ ListAtEnd,
+ ListAtStart,
+ NoResults,
+ HasResults,
+ HasContext,
+}
+
+/// Boolean expression tree over condition atoms.
+///
+/// Supports negation, conjunction, and disjunction with standard precedence:
+/// `!` binds tightest, then `&&`, then `||`.
+///
+/// Examples of valid expression strings:
+/// - `"cursor-at-start"` (bare atom)
+/// - `"!no-results"` (negation)
+/// - `"cursor-at-start && input-empty"` (conjunction)
+/// - `"list-at-start || no-results"` (disjunction)
+/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum ConditionExpr {
+ Atom(ConditionAtom),
+ Not(Box<Self>),
+ And(Box<Self>, Box<Self>),
+ Or(Box<Self>, Box<Self>),
+}
+
+/// Context needed to evaluate conditions. This is a pure snapshot of state —
+/// no references to mutable data.
+pub(crate) struct EvalContext {
+ /// Current cursor position (unicode width units).
+ pub(crate) cursor_position: usize,
+ /// Width of the input string in unicode width units.
+ pub(crate) input_width: usize,
+ /// Byte length of the input string.
+ pub(crate) input_byte_len: usize,
+ /// Currently selected index in the results list.
+ pub(crate) selected_index: usize,
+ /// Total number of results.
+ pub(crate) results_len: usize,
+ /// Whether the original input (query passed to the TUI) was empty.
+ pub(crate) original_input_empty: bool,
+ /// Whether we use a search context of a command from the history.
+ pub(crate) has_context: bool,
+}
+
+// ---------------------------------------------------------------------------
+// ConditionAtom
+// ---------------------------------------------------------------------------
+
+impl ConditionAtom {
+ /// Evaluate this atom against the given context.
+ pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ Self::CursorAtStart => ctx.cursor_position == 0,
+ Self::CursorAtEnd => ctx.cursor_position == ctx.input_width,
+ Self::InputEmpty => ctx.input_byte_len == 0,
+ Self::OriginalInputEmpty => ctx.original_input_empty,
+ Self::ListAtEnd => {
+ ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
+ }
+ Self::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
+ Self::NoResults => ctx.results_len == 0,
+ Self::HasResults => ctx.results_len > 0,
+ Self::HasContext => ctx.has_context,
+ }
+ }
+
+ /// Parse from a kebab-case string.
+ pub(crate) fn from_str(s: &str) -> Result<Self, String> {
+ match s {
+ "cursor-at-start" => Ok(Self::CursorAtStart),
+ "cursor-at-end" => Ok(Self::CursorAtEnd),
+ "input-empty" => Ok(Self::InputEmpty),
+ "original-input-empty" => Ok(Self::OriginalInputEmpty),
+ "list-at-end" => Ok(Self::ListAtEnd),
+ "list-at-start" => Ok(Self::ListAtStart),
+ "no-results" => Ok(Self::NoResults),
+ "has-results" => Ok(Self::HasResults),
+ "has-context" => Ok(Self::HasContext),
+ _ => Err(format!("unknown condition: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub(crate) fn as_str(&self) -> &'static str {
+ match self {
+ Self::CursorAtStart => "cursor-at-start",
+ Self::CursorAtEnd => "cursor-at-end",
+ Self::InputEmpty => "input-empty",
+ Self::OriginalInputEmpty => "original-input-empty",
+ Self::ListAtEnd => "list-at-end",
+ Self::ListAtStart => "list-at-start",
+ Self::NoResults => "no-results",
+ Self::HasResults => "has-results",
+ Self::HasContext => "has-context",
+ }
+ }
+}
+
+impl fmt::Display for ConditionAtom {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — evaluation
+// ---------------------------------------------------------------------------
+
+impl ConditionExpr {
+ /// Evaluate this expression against the given context.
+ pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ Self::Atom(atom) => atom.evaluate(ctx),
+ Self::Not(inner) => !inner.evaluate(ctx),
+ Self::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
+ Self::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — ergonomic builders
+// ---------------------------------------------------------------------------
+
+impl From<ConditionAtom> for ConditionExpr {
+ fn from(atom: ConditionAtom) -> Self {
+ Self::Atom(atom)
+ }
+}
+
+#[expect(dead_code)]
+impl ConditionExpr {
+ /// Negate this expression: `!self`.
+ pub(crate) fn not(self) -> Self {
+ Self::Not(Box::new(self))
+ }
+
+ /// Conjoin with another expression: `self && other`.
+ pub(crate) fn and(self, other: Self) -> Self {
+ Self::And(Box::new(self), Box::new(other))
+ }
+
+ /// Disjoin with another expression: `self || other`.
+ pub(crate) fn or(self, other: Self) -> Self {
+ Self::Or(Box::new(self), Box::new(other))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — parser
+// ---------------------------------------------------------------------------
+
+/// Recursive descent parser for boolean condition expressions.
+///
+/// Grammar (standard boolean precedence):
+/// ```text
+/// expr = or_expr
+/// or_expr = and_expr ("||" and_expr)*
+/// and_expr = unary ("&&" unary)*
+/// unary = "!" unary | primary
+/// primary = atom | "(" expr ")"
+/// atom = [a-z][a-z0-9-]*
+/// ```
+struct ExprParser<'a> {
+ input: &'a str,
+ pos: usize,
+}
+
+impl<'a> ExprParser<'a> {
+ fn new(input: &'a str) -> Self {
+ Self { input, pos: 0 }
+ }
+
+ fn skip_whitespace(&mut self) {
+ while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {
+ self.pos += 1;
+ }
+ }
+
+ fn starts_with(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ self.input[self.pos..].starts_with(s)
+ }
+
+ fn consume(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ if self.input[self.pos..].starts_with(s) {
+ self.pos += s.len();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Parse a full expression, expecting to consume all input.
+ fn parse(mut self) -> Result<ConditionExpr, String> {
+ let expr = self.parse_or()?;
+ self.skip_whitespace();
+ if self.pos < self.input.len() {
+ return Err(format!(
+ "unexpected input at position {}: {:?}",
+ self.pos,
+ &self.input[self.pos..]
+ ));
+ }
+ Ok(expr)
+ }
+
+ /// `or_expr` = `and_expr` ("||" `and_expr`)*
+ fn parse_or(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_and()?;
+ while self.starts_with("||") {
+ self.consume("||");
+ let right = self.parse_and()?;
+ left = ConditionExpr::Or(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// `and_expr` = unary ("&&" unary)*
+ fn parse_and(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_unary()?;
+ while self.starts_with("&&") {
+ self.consume("&&");
+ let right = self.parse_unary()?;
+ left = ConditionExpr::And(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// unary = "!" unary | primary
+ fn parse_unary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("!") {
+ let inner = self.parse_unary()?;
+ Ok(ConditionExpr::Not(Box::new(inner)))
+ } else {
+ self.parse_primary()
+ }
+ }
+
+ /// primary = "(" expr ")" | atom
+ fn parse_primary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("(") {
+ let expr = self.parse_or()?;
+ if !self.consume(")") {
+ return Err(format!("expected ')' at position {}", self.pos));
+ }
+ Ok(expr)
+ } else {
+ self.parse_atom()
+ }
+ }
+
+ /// atom = [a-z][a-z0-9-]*
+ fn parse_atom(&mut self) -> Result<ConditionExpr, String> {
+ self.skip_whitespace();
+ let start = self.pos;
+ while self.pos < self.input.len() {
+ let b = self.input.as_bytes()[self.pos];
+ if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
+ self.pos += 1;
+ } else {
+ break;
+ }
+ }
+ if self.pos == start {
+ return Err(format!("expected condition name at position {}", self.pos));
+ }
+ let name = &self.input[start..self.pos];
+ let atom = ConditionAtom::from_str(name)?;
+ Ok(ConditionExpr::Atom(atom))
+ }
+}
+
+impl ConditionExpr {
+ /// Parse a condition expression from a string.
+ pub(crate) fn parse(s: &str) -> Result<Self, String> {
+ let parser = ExprParser::new(s);
+ parser.parse()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — Display
+// ---------------------------------------------------------------------------
+
+/// Precedence levels for minimal-parentheses display.
+#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
+enum Prec {
+ Or = 0,
+ And = 1,
+ Not = 2,
+ Atom = 3,
+}
+
+impl ConditionExpr {
+ fn prec(&self) -> Prec {
+ match self {
+ Self::Or(..) => Prec::Or,
+ Self::And(..) => Prec::And,
+ Self::Not(..) => Prec::Not,
+ Self::Atom(..) => Prec::Atom,
+ }
+ }
+
+ fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {
+ let needs_parens = self.prec() < parent_prec;
+ if needs_parens {
+ write!(f, "(")?;
+ }
+ match self {
+ Self::Atom(atom) => write!(f, "{atom}")?,
+ Self::Not(inner) => {
+ write!(f, "!")?;
+ inner.fmt_with_prec(f, Prec::Not)?;
+ }
+ Self::And(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::And)?;
+ write!(f, " && ")?;
+ rhs.fmt_with_prec(f, Prec::And)?;
+ }
+ Self::Or(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::Or)?;
+ write!(f, " || ")?;
+ rhs.fmt_with_prec(f, Prec::Or)?;
+ }
+ }
+ if needs_parens {
+ write!(f, ")")?;
+ }
+ Ok(())
+ }
+}
+
+impl fmt::Display for ConditionExpr {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.fmt_with_prec(f, Prec::Or)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Serde
+// ---------------------------------------------------------------------------
+
+impl Serialize for ConditionExpr {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for ConditionExpr {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ Self::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::{ConditionAtom, ConditionExpr, EvalContext};
+
+ fn ctx(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ ) -> EvalContext {
+ ctx_with_original(cursor, width, byte_len, selected, len, false)
+ }
+
+ fn ctx_with_original(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ original_input_empty: bool,
+ ) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: byte_len,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty,
+ has_context: false,
+ }
+ }
+
+ // -- Atom evaluation (carried over from Phase 0) --
+
+ #[test]
+ fn atom_cursor_at_start() {
+ assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_cursor_at_end() {
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ #[test]
+ fn atom_input_empty() {
+ assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));
+ assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_original_input_empty() {
+ // original_input_empty = true
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))
+ );
+ // original_input_empty = false
+ assert!(
+ !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))
+ );
+ // original_input_empty is independent of current input state
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))
+ );
+ }
+
+ #[test]
+ fn atom_list_at_end() {
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));
+ assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_list_at_start() {
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));
+ assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_no_results_and_has_results() {
+ assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_has_context() {
+ let mut context = ctx(0, 0, 0, 0, 0);
+ assert!(!ConditionAtom::HasContext.evaluate(&context));
+ context.has_context = true;
+ assert!(ConditionAtom::HasContext.evaluate(&context));
+ }
+
+ #[test]
+ fn atom_parse_round_trip() {
+ let conditions = [
+ "cursor-at-start",
+ "cursor-at-end",
+ "input-empty",
+ "original-input-empty",
+ "list-at-end",
+ "list-at-start",
+ "no-results",
+ "has-results",
+ ];
+ for s in conditions {
+ let c = ConditionAtom::from_str(s).unwrap();
+ assert_eq!(c.as_str(), s);
+ }
+ }
+
+ #[test]
+ fn atom_parse_unknown() {
+ assert!(ConditionAtom::from_str("unknown-condition").is_err());
+ }
+
+ // -- Parser tests --
+
+ #[test]
+ fn parse_bare_atom() {
+ let expr = ConditionExpr::parse("cursor-at-start").unwrap();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ #[test]
+ fn parse_negation() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))
+ );
+ }
+
+ #[test]
+ fn parse_double_negation() {
+ let expr = ConditionExpr::parse("!!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::NoResults
+ )))))
+ );
+ }
+
+ #[test]
+ fn parse_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_precedence_and_binds_tighter_than_or() {
+ // "a || b && c" should parse as "a || (b && c)"
+ let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_parens_override_precedence() {
+ // "(a || b) && c"
+ let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_complex_nested() {
+ // "(a && !b) || c"
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty
+ )))),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_whitespace_tolerance() {
+ let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap();
+ let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap();
+ let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap();
+ assert_eq!(a, b);
+ assert_eq!(b, c);
+ }
+
+ #[test]
+ fn parse_error_unknown_atom() {
+ assert!(ConditionExpr::parse("unknown-thing").is_err());
+ }
+
+ #[test]
+ fn parse_error_trailing_input() {
+ assert!(ConditionExpr::parse("cursor-at-start blah").is_err());
+ }
+
+ #[test]
+ fn parse_error_unmatched_paren() {
+ assert!(ConditionExpr::parse("(cursor-at-start").is_err());
+ }
+
+ #[test]
+ fn parse_error_empty() {
+ assert!(ConditionExpr::parse("").is_err());
+ }
+
+ // -- Expression evaluation --
+
+ #[test]
+ fn eval_not() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ // Has results → !no-results is true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));
+ // No results → !no-results is false
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn eval_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ // Both true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // First true, second false (non-empty input)
+ assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // First false (cursor not at start)
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn eval_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ // list at bottom (selected=0)
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // no results
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ // neither
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));
+ }
+
+ #[test]
+ fn eval_complex_nested() {
+ // (cursor-at-start && !input-empty) || no-results
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+
+ // cursor at start, input not empty → true (left branch)
+ assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // no results → true (right branch)
+ assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));
+ // cursor not at start, has results → false
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ // cursor at start, input empty → false (left: && fails; right: has results)
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ // -- Display --
+
+ #[test]
+ fn display_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ assert_eq!(expr.to_string(), "cursor-at-start");
+ }
+
+ #[test]
+ fn display_not() {
+ let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();
+ assert_eq!(expr.to_string(), "!no-results");
+ }
+
+ #[test]
+ fn display_and() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));
+ assert_eq!(expr.to_string(), "cursor-at-start && input-empty");
+ }
+
+ #[test]
+ fn display_or() {
+ let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(expr.to_string(), "list-at-start || no-results");
+ }
+
+ #[test]
+ fn display_parens_when_needed() {
+ // (a || b) && c — the Or inside And needs parens
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(
+ expr.to_string(),
+ "(cursor-at-start || input-empty) && no-results"
+ );
+ }
+
+ #[test]
+ fn display_no_parens_when_not_needed() {
+ // a || b && c — no parens needed (and binds tighter)
+ let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start || input-empty && no-results"
+ );
+ }
+
+ // -- Display round-trip --
+
+ #[test]
+ fn display_round_trip() {
+ let cases = [
+ "cursor-at-start",
+ "!no-results",
+ "cursor-at-start && input-empty",
+ "list-at-start || no-results",
+ "(cursor-at-start || input-empty) && no-results",
+ "(cursor-at-start && !input-empty) || no-results",
+ ];
+ for s in cases {
+ let expr = ConditionExpr::parse(s).unwrap();
+ let displayed = expr.to_string();
+ let reparsed = ConditionExpr::parse(&displayed).unwrap();
+ assert_eq!(expr, reparsed, "round-trip failed for: {s}");
+ }
+ }
+
+ // -- Serde --
+
+ #[test]
+ fn serde_simple_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ let json = serde_json::to_string(&expr).unwrap();
+ assert_eq!(json, "\"cursor-at-start\"");
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, expr);
+ }
+
+ #[test]
+ fn serde_compound_expression() {
+ let json = "\"cursor-at-start && !input-empty\"";
+ let parsed: ConditionExpr = serde_json::from_str(json).unwrap();
+ let expected = ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty,
+ )))),
+ );
+ assert_eq!(parsed, expected);
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ let json = serde_json::to_string(&expr).unwrap();
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(expr, parsed);
+ }
+
+ // -- From<ConditionAtom> --
+
+ #[test]
+ fn from_atom_into_expr() {
+ let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ // -- Builder helpers --
+
+ #[test]
+ fn builder_chain() {
+ let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())
+ .or(ConditionExpr::from(ConditionAtom::NoResults));
+ // And binds tighter than Or, so no parens needed around the And
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start && !input-empty || no-results"
+ );
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/defaults.rs b/crates/client/src/command/client/search/keybindings/defaults.rs
new file mode 100644
index 00000000..4d00b475
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/defaults.rs
@@ -0,0 +1,1274 @@
+use std::collections::HashMap;
+
+use crate::atuin_client::settings::{KeyBindingConfig, Settings};
+use tracing::warn;
+
+use super::actions::Action;
+use super::conditions::{ConditionAtom, ConditionExpr};
+use super::key::KeyInput;
+use super::keymap::{KeyBinding, KeyRule, Keymap};
+
+/// Helper to bind a scroll key with optional exit behavior.
+///
+/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest
+/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →
+/// the scroll action.
+///
+/// Whether a key scrolls toward index 0 depends on the `invert` setting:
+/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away
+/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away
+///
+/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind
+/// the key to the plain scroll action (no exit).
+fn bind_scroll_key(
+ km: &mut Keymap,
+ key_str: &str,
+ action: Action,
+ toward_index_zero: bool,
+ scroll_exits: bool,
+) {
+ let k = key(key_str);
+ if scroll_exits && toward_index_zero {
+ km.bind_conditional(
+ k,
+ vec![
+ KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),
+ KeyRule::always(action),
+ ],
+ );
+ } else {
+ km.bind(k, action);
+ }
+}
+
+/// Helper to parse a key string, panicking on invalid keys (these are all
+/// compile-time-known strings).
+fn key(s: &str) -> KeyInput {
+ KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}"))
+}
+
+/// All five keymaps bundled together.
+#[derive(Debug, Clone)]
+pub(crate) struct KeymapSet {
+ pub(crate) emacs: Keymap,
+ pub(crate) vim_normal: Keymap,
+ pub(crate) vim_insert: Keymap,
+ pub(crate) inspector: Keymap,
+ pub(crate) prefix: Keymap,
+}
+
+// ---------------------------------------------------------------------------
+// Common bindings shared across search-tab keymaps
+// ---------------------------------------------------------------------------
+
+/// Add the bindings that are common to all search-tab keymaps:
+/// ctrl-c, ctrl-g, ctrl-o, and tab.
+///
+/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs
+/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).
+fn add_common_bindings(km: &mut Keymap) {
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Tab: always returns selection without executing (unlike Enter which respects enter_accept)
+ km.bind(key("tab"), Action::ReturnSelection);
+}
+
+/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.
+fn accept_action(settings: &Settings) -> Action {
+ if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Emacs keymap (also base for vim-insert)
+// ---------------------------------------------------------------------------
+
+/// Build the default emacs keymap. This encodes the behavior from
+/// `handle_key_input` common section + `handle_search_input` shared section.
+///
+/// The `settings` parameter is used for:
+/// - `keys.prefix` — which ctrl-key enters prefix mode
+/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior
+/// - `keys.accept_past_line_end` — right arrow at end of line accepts
+/// - `keys.exit_past_line_start` — left arrow at start of line exits
+/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)
+/// - `keys.accept_with_backspace` — backspace at start of line accepts
+/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts
+// Keymap builder that enumerates every default binding; not worth splitting.
+#[expect(clippy::too_many_lines)]
+pub(crate) fn default_emacs_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km);
+
+ let accept = accept_action(settings);
+
+ // esc / ctrl-[ → exit
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key: ctrl-<prefix_char> → enter prefix mode
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Accept / navigation edge behaviors (from [keys] settings) ---
+
+ // right: behavior at end of line
+ if settings.keys.accept_past_line_end {
+ km.bind_conditional(
+ key("right"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection),
+ KeyRule::always(Action::CursorRight),
+ ],
+ );
+ } else {
+ km.bind(key("right"), Action::CursorRight);
+ }
+
+ // left: behavior at start of line
+ // accept_past_line_start takes precedence over exit_past_line_start
+ if settings.keys.accept_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else if settings.keys.exit_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else {
+ km.bind(key("left"), Action::CursorLeft);
+ }
+
+ // down/up: scroll with optional exit at boundary.
+ // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).
+ // Inverted: up moves toward index 0 (can exit); down moves away (no exit).
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // backspace: behavior at start of line
+ if settings.keys.accept_with_backspace {
+ km.bind_conditional(
+ key("backspace"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
+ KeyRule::always(Action::DeleteCharBefore),
+ ],
+ );
+ } else {
+ km.bind(key("backspace"), Action::DeleteCharBefore);
+ }
+
+ // --- Accept ---
+ km.bind(key("enter"), accept.clone());
+ km.bind(key("ctrl-m"), accept);
+
+ // --- Copy ---
+ km.bind(key("ctrl-y"), Action::Copy);
+
+ // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---
+ // These return the selection without executing, regardless of enter_accept.
+ let num_mod = if settings.ctrl_n_shortcuts {
+ "ctrl"
+ } else {
+ "alt"
+ };
+ for n in 1..=9u8 {
+ km.bind(
+ key(&format!("{num_mod}-{n}")),
+ Action::ReturnSelectionNth(n),
+ );
+ }
+
+ // --- Cursor movement ---
+ km.bind(key("ctrl-left"), Action::CursorWordLeft);
+ km.bind(key("alt-b"), Action::CursorWordLeft);
+ km.bind(key("ctrl-b"), Action::CursorLeft);
+ km.bind(key("ctrl-right"), Action::CursorWordRight);
+ km.bind(key("alt-f"), Action::CursorWordRight);
+ km.bind(key("ctrl-f"), Action::CursorRight);
+ km.bind(key("home"), Action::CursorStart);
+ // ctrl-a → CursorStart only if prefix char is NOT 'a'
+ // (otherwise ctrl-a is already bound to EnterPrefixMode above)
+ if prefix_char != 'a' {
+ km.bind(key("ctrl-a"), Action::CursorStart);
+ }
+ km.bind(key("ctrl-e"), Action::CursorEnd);
+ km.bind(key("end"), Action::CursorEnd);
+
+ // --- Editing ---
+ km.bind(key("ctrl-backspace"), Action::DeleteWordBefore);
+ km.bind(key("ctrl-h"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-?"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-delete"), Action::DeleteWordAfter);
+ km.bind(key("delete"), Action::DeleteCharAfter);
+ // ctrl-d: if input empty → return original, otherwise delete char
+ km.bind_conditional(
+ key("ctrl-d"),
+ vec![
+ KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),
+ KeyRule::always(Action::DeleteCharAfter),
+ ],
+ );
+ km.bind(key("ctrl-w"), Action::DeleteToWordBoundary);
+ km.bind(key("ctrl-u"), Action::ClearLine);
+
+ // --- Search mode ---
+ km.bind(key("ctrl-r"), Action::CycleFilterMode);
+ km.bind(key("ctrl-s"), Action::CycleSearchMode);
+
+ // --- Scroll (no exit) ---
+ km.bind(key("ctrl-n"), Action::SelectNext);
+ km.bind(key("ctrl-j"), Action::SelectNext);
+ km.bind(key("ctrl-p"), Action::SelectPrevious);
+ km.bind(key("ctrl-k"), Action::SelectPrevious);
+
+ // --- Redraw ---
+ km.bind(key("ctrl-l"), Action::Redraw);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Normal keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-normal keymap.
+pub(crate) fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km);
+
+ // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Vim navigation ---
+ // j/k: scroll with optional exit at boundary.
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits);
+ km.bind(key("h"), Action::CursorLeft);
+ km.bind(key("l"), Action::CursorRight);
+
+ // --- Vim cursor movement ---
+ km.bind(key("0"), Action::CursorStart);
+ km.bind(key("$"), Action::CursorEnd);
+ km.bind(key("w"), Action::CursorWordRight);
+ km.bind(key("b"), Action::CursorWordLeft);
+ km.bind(key("e"), Action::CursorWordEnd);
+
+ // --- Vim editing ---
+ km.bind(key("x"), Action::DeleteCharAfter);
+ km.bind(key("d d"), Action::ClearLine);
+ km.bind(key("D"), Action::ClearToEnd);
+ km.bind(key("C"), Action::VimChangeToEnd);
+
+ // --- Mode switching ---
+ km.bind(key("?"), Action::VimSearchInsert);
+ km.bind(key("/"), Action::VimSearchInsert);
+ km.bind(key("a"), Action::VimEnterInsertAfter);
+ km.bind(key("A"), Action::VimEnterInsertAtEnd);
+ km.bind(key("i"), Action::VimEnterInsert);
+ km.bind(key("I"), Action::VimEnterInsertAtStart);
+
+ // --- Numeric shortcuts (return selection without executing) ---
+ for n in 1..=9u8 {
+ km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));
+ }
+
+ // --- Half/full page scroll ---
+ km.bind(key("ctrl-u"), Action::ScrollHalfPageUp);
+ km.bind(key("ctrl-d"), Action::ScrollHalfPageDown);
+ km.bind(key("ctrl-b"), Action::ScrollPageUp);
+ km.bind(key("ctrl-f"), Action::ScrollPageDown);
+
+ // --- Jump ---
+ km.bind(key("G"), Action::ScrollToBottom);
+ km.bind(key("g g"), Action::ScrollToTop);
+ km.bind(key("H"), Action::ScrollToScreenTop);
+ km.bind(key("M"), Action::ScrollToScreenMiddle);
+ km.bind(key("L"), Action::ScrollToScreenBottom);
+
+ // --- Arrow keys (same as emacs for convenience) ---
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ // --- Accept ---
+ let accept = accept_action(settings);
+ km.bind(key("enter"), accept);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Insert keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-insert keymap. This clones the emacs keymap and
+/// overlays vim-insert-specific bindings (esc → enter normal mode).
+pub(crate) fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
+ let mut km = default_emacs_keymap(settings);
+
+ // Override esc and ctrl-[ to enter normal mode instead of exiting
+ km.bind(key("esc"), Action::VimEnterNormal);
+ km.bind(key("ctrl-["), Action::VimEnterNormal);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Inspector keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default inspector keymap (tab index 1).
+///
+/// The inspector shows details about the selected history item and has no
+/// text input, so we build a minimal keymap with only inspector-relevant
+/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
+/// navigation for vim users.
+pub(crate) fn default_inspector_keymap(settings: &Settings) -> Keymap {
+ use crate::atuin_client::settings::KeymapMode;
+
+ let mut km = Keymap::new();
+
+ // Common bindings (same as search tab)
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+ km.bind(key("tab"), Action::ReturnSelection);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Accept behavior respects enter_accept setting
+ let accept = if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ };
+ km.bind(key("enter"), accept);
+
+ // Inspector-specific: delete history entry
+ km.bind(key("ctrl-d"), Action::Delete);
+
+ // Inspector navigation
+ km.bind(key("up"), Action::InspectPrevious);
+ km.bind(key("down"), Action::InspectNext);
+ km.bind(key("pageup"), Action::InspectPrevious);
+ km.bind(key("pagedown"), Action::InspectNext);
+
+ // For vim users, add j/k navigation
+ if matches!(
+ settings.keymap_mode,
+ KeymapMode::VimNormal | KeymapMode::VimInsert
+ ) {
+ km.bind(key("j"), Action::InspectNext);
+ km.bind(key("k"), Action::InspectPrevious);
+ }
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Prefix keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default prefix keymap (active after ctrl-a prefix).
+pub(crate) fn default_prefix_keymap() -> Keymap {
+ let mut km = Keymap::new();
+
+ km.bind(key("d"), Action::Delete);
+ km.bind(key("D"), Action::DeleteAll);
+ km.bind(key("a"), Action::CursorStart);
+ km.bind_conditional(
+ key("c"),
+ vec![
+ KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
+ KeyRule::always(Action::SwitchContext),
+ ],
+ );
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// KeymapSet construction
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Config → Keymap conversion
+// ---------------------------------------------------------------------------
+
+/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.
+/// Returns `Err` if an action name or condition expression is invalid.
+fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {
+ match config {
+ KeyBindingConfig::Simple(action_str) => {
+ let action = Action::from_str(action_str)?;
+ Ok(KeyBinding::simple(action))
+ }
+ KeyBindingConfig::Rules(rules) => {
+ let mut parsed_rules = Vec::with_capacity(rules.len());
+ for rule_cfg in rules {
+ let action = Action::from_str(&rule_cfg.action)?;
+ let rule = match &rule_cfg.when {
+ None => KeyRule::always(action),
+ Some(cond_str) => {
+ let cond = ConditionExpr::parse(cond_str)?;
+ KeyRule::when(cond, action)
+ }
+ };
+ parsed_rules.push(rule);
+ }
+ Ok(KeyBinding::conditional(parsed_rules))
+ }
+ }
+}
+
+/// Apply a map of key-string → binding-config overrides to a keymap.
+/// Per-key override replaces the entire rule list for that key.
+/// Invalid keys or action names are logged and skipped.
+fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {
+ for (key_str, binding_cfg) in overrides {
+ let key = match KeyInput::parse(key_str) {
+ Ok(k) => k,
+ Err(e) => {
+ warn!("invalid key in keymap config: {key_str:?}: {e}");
+ continue;
+ }
+ };
+ match parse_binding_config(binding_cfg) {
+ Ok(binding) => {
+ keymap.bindings.insert(key, binding);
+ }
+ Err(e) => {
+ warn!("invalid binding for {key_str:?} in keymap config: {e}");
+ }
+ }
+ }
+}
+
+impl KeymapSet {
+ /// Build the complete set of default keymaps from settings.
+ pub(crate) fn defaults(settings: &Settings) -> Self {
+ Self {
+ emacs: default_emacs_keymap(settings),
+ vim_normal: default_vim_normal_keymap(settings),
+ vim_insert: default_vim_insert_keymap(settings),
+ inspector: default_inspector_keymap(settings),
+ prefix: default_prefix_keymap(),
+ }
+ }
+
+ /// Build keymaps from settings, applying any user `[keymap]` overrides.
+ ///
+ /// Precedence rules:
+ /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.
+ /// Defaults are built with standard `[keys]` values, then `[keymap]`
+ /// overrides are applied per-key.
+ /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults
+ /// (current behavior for backward compatibility).
+ pub(crate) fn from_settings(settings: &Settings) -> Self {
+ use crate::atuin_client::settings::Keys;
+
+ if settings.keymap.is_empty() {
+ // No [keymap] section → use [keys] to customize defaults
+ Self::defaults(settings)
+ } else {
+ // [keymap] present → ignore [keys], use standard defaults as base
+ let mut base_settings = settings.clone();
+ base_settings.keys = Keys::standard_defaults();
+ let mut set = Self::defaults(&base_settings);
+ set.apply_config(settings);
+ set
+ }
+ }
+
+ /// Apply user keymap config overrides to all modes.
+ fn apply_config(&mut self, settings: &Settings) {
+ let config = &settings.keymap;
+ apply_config_to_keymap(&mut self.emacs, &config.emacs);
+ apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);
+ apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);
+ apply_config_to_keymap(&mut self.inspector, &config.inspector);
+ apply_config_to_keymap(&mut self.prefix, &config.prefix);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap,
+ default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key,
+ parse_binding_config,
+ };
+ use crate::command::client::search::keybindings::conditions::EvalContext;
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ fn default_settings() -> Settings {
+ Settings::new().unwrap()
+ }
+
+ // -- Emacs keymap tests --
+
+ #[test]
+ fn emacs_ctrl_c_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_esc_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_tab_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ #[test]
+ fn emacs_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ #[test]
+ fn emacs_right_at_end_returns_selection() {
+ let km = default_emacs_keymap(&default_settings());
+ // cursor at end of "hello" (width 5)
+ let ctx = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("right"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_right_not_at_end_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight));
+ }
+
+ #[test]
+ fn emacs_left_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_left_not_at_start_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn emacs_down_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_down_not_at_start_selects_next() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=5 → not at start → SelectNext
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_up_selects_previous() {
+ let km = default_emacs_keymap(&default_settings());
+ // Non-inverted: up never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn emacs_ctrl_d_empty_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ // input empty (byte_len = 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_d_nonempty_deletes() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::DeleteCharAfter)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_n_selects_next_no_exit_condition() {
+ let km = default_emacs_keymap(&default_settings());
+ // at start, but ctrl-n should NOT exit (no exit condition bound)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_prefix_key_enters_prefix() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-a"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ }
+
+ #[test]
+ fn emacs_home_cursor_start() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(5, 10, 0, 10);
+ assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart));
+ }
+
+ // -- Vim Normal keymap tests --
+
+ #[test]
+ fn vim_normal_j_at_start_exits() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn vim_normal_j_not_at_start_selects_next() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn vim_normal_k_selects_previous() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // Non-inverted: k never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn vim_normal_i_enters_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert));
+ }
+
+ #[test]
+ fn vim_normal_slash_search_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert));
+ }
+
+ #[test]
+ fn vim_normal_gg_scroll_to_top() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop));
+ }
+
+ #[test]
+ fn vim_normal_big_g_scroll_to_bottom() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom));
+ }
+
+ #[test]
+ fn vim_normal_numeric_returns_selection() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("3"), &ctx),
+ Some(Action::ReturnSelectionNth(3))
+ );
+ }
+
+ #[test]
+ fn vim_normal_ctrl_u_half_page_up() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(
+ km.resolve(&key("ctrl-u"), &ctx),
+ Some(Action::ScrollHalfPageUp)
+ );
+ }
+
+ #[test]
+ fn vim_normal_screen_jumps() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop));
+ assert_eq!(
+ km.resolve(&key("M"), &ctx),
+ Some(Action::ScrollToScreenMiddle)
+ );
+ assert_eq!(
+ km.resolve(&key("L"), &ctx),
+ Some(Action::ScrollToScreenBottom)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_vim_normal_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ }
+
+ // -- Vim Insert keymap tests --
+
+ #[test]
+ fn vim_insert_inherits_emacs_enter() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // enter_accept=false → ReturnSelection
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_insert_esc_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal));
+ }
+
+ #[test]
+ fn vim_insert_ctrl_bracket_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-["), &ctx),
+ Some(Action::VimEnterNormal)
+ );
+ }
+
+ #[test]
+ fn vim_insert_inherits_emacs_ctrl_d() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // input empty → return original
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ // -- Inspector keymap tests --
+
+ #[test]
+ fn inspector_ctrl_d_deletes() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn inspector_up_inspects_previous() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious));
+ }
+
+ #[test]
+ fn inspector_down_inspects_next() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext));
+ }
+
+ #[test]
+ fn inspector_esc_exits() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn inspector_tab_returns_selection() {
+ // enter_accept=false → ReturnSelection
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ // -- Prefix keymap tests --
+
+ #[test]
+ fn prefix_d_deletes() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn prefix_a_cursor_start() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn prefix_unknown_key_returns_none() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("x"), &ctx), None);
+ }
+
+ // -- KeymapSet tests --
+
+ #[test]
+ fn keymap_set_defaults_builds() {
+ let settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // Sanity check each keymap has bindings
+ assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.prefix.resolve(&key("d"), &ctx).is_some());
+ }
+
+ // -- Settings-dependent behavior --
+
+ #[test]
+ fn custom_prefix_char() {
+ let mut settings = default_settings();
+ settings.keys.prefix = "x".to_string();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-x should be prefix mode
+ assert_eq!(
+ km.resolve(&key("ctrl-x"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ // ctrl-a should now be CursorStart (not prefix)
+ assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn ctrl_n_shortcuts_changes_numeric_modifier() {
+ let mut settings = default_settings();
+ settings.ctrl_n_shortcuts = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-1 should work
+ assert_eq!(
+ km.resolve(&key("ctrl-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ // alt-1 should NOT be bound
+ assert_eq!(km.resolve(&key("alt-1"), &ctx), None);
+ }
+
+ #[test]
+ fn default_alt_numeric_shortcuts() {
+ let settings = default_settings();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // alt-1 should work by default
+ assert_eq!(
+ km.resolve(&key("alt-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Config parsing and merging tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn parse_simple_binding_config() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("accept".to_string());
+ let binding = parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 1);
+ assert!(binding.rules[0].condition.is_none());
+ assert_eq!(binding.rules[0].action, Action::Accept);
+ }
+
+ #[test]
+ fn parse_conditional_binding_config() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("cursor-at-start".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "cursor-left".to_string(),
+ },
+ ]);
+ let binding = parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 2);
+ assert!(binding.rules[0].condition.is_some());
+ assert_eq!(binding.rules[0].action, Action::Exit);
+ assert!(binding.rules[1].condition.is_none());
+ assert_eq!(binding.rules[1].action, Action::CursorLeft);
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_action() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string());
+ assert!(parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_condition() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {
+ when: Some("not-a-real-condition".to_string()),
+ action: "exit".to_string(),
+ }]);
+ assert!(parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn config_override_replaces_key() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+
+ // Default: ctrl-c → ReturnOriginal
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+
+ // Override ctrl-c → Exit via config
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn config_override_preserves_unoverridden_keys() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override only ctrl-c; enter should keep its default
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-c overridden
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ // enter still has default (enter_accept=false → ReturnSelection)
+ assert_eq!(
+ set.emacs.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn config_conditional_override() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override "up" with a custom conditional
+ settings.keymap.emacs = HashMap::from([(
+ "up".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("no-results".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "select-previous".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // With no results → exit
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit));
+
+ // With results → select-previous
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("up"), &ctx),
+ Some(Action::SelectPrevious)
+ );
+ }
+
+ #[test]
+ fn from_settings_with_empty_config_equals_defaults() {
+ let settings = default_settings();
+ let defaults = KeymapSet::defaults(&settings);
+ let from_settings = KeymapSet::from_settings(&settings);
+
+ // Verify a sample of keys produce the same results
+ let ctx = make_ctx(0, 0, 0, 10);
+ let test_keys = [
+ "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right",
+ ];
+ for k in &test_keys {
+ assert_eq!(
+ defaults.emacs.resolve(&key(k), &ctx),
+ from_settings.emacs.resolve(&key(k), &ctx),
+ "mismatch for emacs key {k}"
+ );
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 5: [keys] vs [keymap] backward compatibility
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn keymap_overrides_ignore_keys_section() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+
+ // Set up: [keys] disables scroll_exits, but [keymap] is present
+ let mut settings = default_settings();
+ settings.keys.scroll_exits = false;
+
+ // Without [keymap], scroll_exits=false means no exit condition on down
+ let set_legacy = KeymapSet::defaults(&settings);
+ // At list-at-start (selected=0), down should still be SelectNext (no exit)
+ let ctx_at_boundary = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::SelectNext),
+ "legacy: down at boundary should be SelectNext with scroll_exits=false"
+ );
+
+ // With [keymap] present (even just one override), [keys] is ignored
+ // so the standard defaults (scroll_exits=true) apply
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Not at boundary (selected=5): should SelectNext normally
+ let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary),
+ Some(Action::SelectNext),
+ "keymap: down not at boundary should SelectNext"
+ );
+ // At list-at-start (selected=0): should Exit (standard scroll_exits=true)
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::Exit),
+ "keymap: down at boundary should Exit (standard defaults restored)"
+ );
+ }
+
+ #[test]
+ fn keymap_present_resets_to_standard_keys_defaults() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+
+ let mut settings = default_settings();
+ // Disable all [keys] behaviors
+ settings.keys.exit_past_line_start = false;
+ settings.keys.accept_past_line_end = false;
+
+ // Without [keymap], left should be plain CursorLeft
+ let set_legacy = KeymapSet::defaults(&settings);
+ let ctx_at_start = make_ctx(0, 5, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::CursorLeft),
+ "legacy: left should be plain CursorLeft without exit_past_line_start"
+ );
+
+ // Add a [keymap] entry (for a different key)
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Now left should use standard defaults (exit_past_line_start=true)
+ // At cursor start → Exit
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::Exit),
+ "keymap: left at cursor start should exit (standard defaults)"
+ );
+
+ // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)
+ let ctx_at_end = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("right"), &ctx_at_end),
+ Some(Action::ReturnSelection),
+ "keymap: right at cursor end should return selection (standard defaults)"
+ );
+ }
+
+ #[test]
+ fn original_input_empty_condition_in_config() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Configure esc to: if original-input-empty -> return-query, else return-original
+ settings.keymap.emacs = HashMap::from([(
+ "esc".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("original-input-empty".to_string()),
+ action: "return-query".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "return-original".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // When original input was empty, should return-query
+ let ctx_original_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: true,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_empty),
+ Some(Action::ReturnQuery),
+ "esc with original_input_empty=true should return-query"
+ );
+
+ // When original input was not empty, should return-original
+ let ctx_original_not_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: false,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
+ Some(Action::ReturnOriginal),
+ "esc with original_input_empty=false should return-original"
+ );
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/key.rs b/crates/client/src/command/client/search/keybindings/key.rs
new file mode 100644
index 00000000..5e772238
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/key.rs
@@ -0,0 +1,633 @@
+use std::fmt;
+
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[expect(clippy::struct_excessive_bools)]
+pub(crate) struct SingleKey {
+ pub(crate) code: KeyCodeValue,
+ pub(crate) ctrl: bool,
+ pub(crate) alt: bool,
+ pub(crate) shift: bool,
+ pub(crate) super_key: bool,
+}
+
+/// The key code portion of a key press.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[expect(
+ variant_size_differences,
+ reason = "It's not that much. So should be ok?"
+)]
+pub(crate) enum KeyCodeValue {
+ Char(char),
+ Enter,
+ Esc,
+ Tab,
+ Backspace,
+ Delete,
+ Insert,
+ Up,
+ Down,
+ Left,
+ Right,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Space,
+ F(u8),
+ Media(MediaKeyCode),
+}
+
+/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum KeyInput {
+ Single(SingleKey),
+ Sequence(Vec<SingleKey>),
+}
+
+impl SingleKey {
+ /// Convert a crossterm `KeyEvent` into a `SingleKey`.
+ pub(crate) fn from_event(event: &KeyEvent) -> Option<Self> {
+ let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
+ let alt = event.modifiers.contains(KeyModifiers::ALT);
+ let shift = event.modifiers.contains(KeyModifiers::SHIFT);
+ let super_key = event.modifiers.contains(KeyModifiers::SUPER);
+
+ let code = match event.code {
+ KeyCode::Char(' ') => KeyCodeValue::Space,
+ KeyCode::Char(c) => {
+ // If shift is the only modifier and it's an uppercase letter,
+ // we store the uppercase char directly and clear the shift flag
+ // since the case already encodes it.
+ if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {
+ return Some(Self {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ });
+ }
+ KeyCodeValue::Char(c)
+ }
+ KeyCode::Enter => KeyCodeValue::Enter,
+ KeyCode::Esc => KeyCodeValue::Esc,
+ KeyCode::Tab => KeyCodeValue::Tab,
+ // BackTab is sent by many terminals for Shift+Tab
+ KeyCode::BackTab => {
+ return Some(Self {
+ code: KeyCodeValue::Tab,
+ ctrl,
+ alt,
+ shift: true,
+ super_key,
+ });
+ }
+ KeyCode::Backspace => KeyCodeValue::Backspace,
+ KeyCode::Delete => KeyCodeValue::Delete,
+ KeyCode::Insert => KeyCodeValue::Insert,
+ KeyCode::Up => KeyCodeValue::Up,
+ KeyCode::Down => KeyCodeValue::Down,
+ KeyCode::Left => KeyCodeValue::Left,
+ KeyCode::Right => KeyCodeValue::Right,
+ KeyCode::Home => KeyCodeValue::Home,
+ KeyCode::End => KeyCodeValue::End,
+ KeyCode::PageUp => KeyCodeValue::PageUp,
+ KeyCode::PageDown => KeyCodeValue::PageDown,
+ KeyCode::F(n) => KeyCodeValue::F(n),
+ KeyCode::Media(m) => KeyCodeValue::Media(m),
+ _ => return None,
+ };
+
+ Some(Self {
+ code,
+ ctrl,
+ alt,
+ shift: if matches!(code, KeyCodeValue::Char(_)) {
+ false
+ } else {
+ shift
+ },
+ super_key,
+ })
+ }
+
+ /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`.
+ pub(crate) fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ let parts: Vec<&str> = s.split('-').collect();
+
+ let mut ctrl = false;
+ let mut alt = false;
+ let mut shift = false;
+ let mut super_key = false;
+
+ // All parts except the last are modifiers
+ for &part in &parts[..parts.len() - 1] {
+ match part.to_lowercase().as_str() {
+ "ctrl" => ctrl = true,
+ "alt" => alt = true,
+ "shift" => shift = true,
+ "super" | "cmd" | "win" => super_key = true,
+ _ => return Err(format!("unknown modifier: {part}")),
+ }
+ }
+
+ let key_part = parts[parts.len() - 1];
+ let code = match key_part.to_lowercase().as_str() {
+ "enter" | "return" => KeyCodeValue::Enter,
+ "esc" | "escape" => KeyCodeValue::Esc,
+ "tab" => KeyCodeValue::Tab,
+ "backspace" => KeyCodeValue::Backspace,
+ "delete" | "del" => KeyCodeValue::Delete,
+ "insert" | "ins" => KeyCodeValue::Insert,
+ "up" => KeyCodeValue::Up,
+ "down" => KeyCodeValue::Down,
+ "left" => KeyCodeValue::Left,
+ "right" => KeyCodeValue::Right,
+ "home" => KeyCodeValue::Home,
+ "end" => KeyCodeValue::End,
+ "pageup" => KeyCodeValue::PageUp,
+ "pagedown" => KeyCodeValue::PageDown,
+ "space" => KeyCodeValue::Space,
+ s if s.starts_with('f') && s.len() > 1 => {
+ // Parse function keys like "f1", "f12"
+ if let Ok(n) = s[1..].parse::<u8>() {
+ if (1..=24).contains(&n) {
+ KeyCodeValue::F(n)
+ } else {
+ return Err(format!("function key out of range: {key_part}"));
+ }
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ "[" => KeyCodeValue::Char('['),
+ "]" => KeyCodeValue::Char(']'),
+ "?" => KeyCodeValue::Char('?'),
+ "/" => KeyCodeValue::Char('/'),
+ "$" => KeyCodeValue::Char('$'),
+ // Media keys (no dashes - the parser splits on dash for modifiers)
+ "play" => KeyCodeValue::Media(MediaKeyCode::Play),
+ "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
+ "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
+ "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
+ "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
+ "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
+ "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
+ "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
+ "record" => KeyCodeValue::Media(MediaKeyCode::Record),
+ "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
+ "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
+ "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
+ _ => {
+ let chars: Vec<char> = key_part.chars().collect();
+ if chars.len() == 1 {
+ let c = chars[0];
+ // An uppercase letter implies shift (unless shift already specified)
+ if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {
+ return Ok(Self {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ });
+ }
+ KeyCodeValue::Char(c)
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ };
+
+ Ok(Self {
+ code,
+ ctrl,
+ alt,
+ shift,
+ super_key,
+ })
+ }
+}
+
+impl fmt::Display for SingleKey {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if self.super_key {
+ write!(f, "super-")?;
+ }
+ if self.ctrl {
+ write!(f, "ctrl-")?;
+ }
+ if self.alt {
+ write!(f, "alt-")?;
+ }
+ if self.shift {
+ write!(f, "shift-")?;
+ }
+ match &self.code {
+ KeyCodeValue::Char(c) => write!(f, "{c}"),
+ KeyCodeValue::Enter => write!(f, "enter"),
+ KeyCodeValue::Esc => write!(f, "esc"),
+ KeyCodeValue::Tab => write!(f, "tab"),
+ KeyCodeValue::Backspace => write!(f, "backspace"),
+ KeyCodeValue::Delete => write!(f, "delete"),
+ KeyCodeValue::Insert => write!(f, "insert"),
+ KeyCodeValue::Up => write!(f, "up"),
+ KeyCodeValue::Down => write!(f, "down"),
+ KeyCodeValue::Left => write!(f, "left"),
+ KeyCodeValue::Right => write!(f, "right"),
+ KeyCodeValue::Home => write!(f, "home"),
+ KeyCodeValue::End => write!(f, "end"),
+ KeyCodeValue::PageUp => write!(f, "pageup"),
+ KeyCodeValue::PageDown => write!(f, "pagedown"),
+ KeyCodeValue::Space => write!(f, "space"),
+ KeyCodeValue::F(n) => write!(f, "f{n}"),
+ KeyCodeValue::Media(m) => match m {
+ MediaKeyCode::Play => write!(f, "play"),
+ MediaKeyCode::Pause => write!(f, "media-pause"),
+ MediaKeyCode::PlayPause => write!(f, "playpause"),
+ MediaKeyCode::Stop => write!(f, "stop"),
+ MediaKeyCode::FastForward => write!(f, "fastforward"),
+ MediaKeyCode::Rewind => write!(f, "rewind"),
+ MediaKeyCode::TrackNext => write!(f, "tracknext"),
+ MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
+ MediaKeyCode::Record => write!(f, "record"),
+ MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
+ MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
+ MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
+ MediaKeyCode::Reverse => write!(f, "reverse"),
+ },
+ }
+ }
+}
+
+impl KeyInput {
+ /// Parse a key input string. Supports multi-key sequences separated by spaces
+ /// (e.g. `"g g"`).
+ pub(crate) fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ // Check for space-separated multi-key sequences
+ // But don't split "space" or modifier combos like "ctrl-a"
+ let parts: Vec<&str> = s.split_whitespace().collect();
+ if parts.len() > 1 {
+ let keys: Result<Vec<SingleKey>, String> =
+ parts.iter().map(|p| SingleKey::parse(p)).collect();
+ Ok(Self::Sequence(keys?))
+ } else {
+ Ok(Self::Single(SingleKey::parse(s)?))
+ }
+ }
+}
+
+impl fmt::Display for KeyInput {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Single(k) => write!(f, "{k}"),
+ Self::Sequence(keys) => {
+ for (i, k) in keys.iter().enumerate() {
+ if i > 0 {
+ write!(f, " ")?;
+ }
+ write!(f, "{k}")?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+impl Serialize for KeyInput {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for KeyInput {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ Self::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{KeyCodeValue, KeyInput, SingleKey};
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn parse_simple_keys() {
+ let k = SingleKey::parse("a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("enter").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Enter);
+
+ let k = SingleKey::parse("esc").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Esc);
+
+ let k = SingleKey::parse("tab").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+
+ let k = SingleKey::parse("space").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Space);
+ }
+
+ #[test]
+ fn parse_modifiers() {
+ let k = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let k = SingleKey::parse("alt-f").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('f'));
+ assert!(k.alt);
+ assert!(!k.ctrl);
+
+ let k = SingleKey::parse("ctrl-alt-x").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('x'));
+ assert!(k.ctrl && k.alt);
+ }
+
+ #[test]
+ fn parse_uppercase_implies_no_shift_flag() {
+ let k = SingleKey::parse("G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(!k.shift);
+ assert!(!k.ctrl);
+ }
+
+ #[test]
+ fn parse_special_chars() {
+ let k = SingleKey::parse("ctrl-[").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('['));
+ assert!(k.ctrl);
+
+ let k = SingleKey::parse("?").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('?'));
+
+ let k = SingleKey::parse("/").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('/'));
+ }
+
+ #[test]
+ fn parse_multi_key_sequence() {
+ let ki = KeyInput::parse("g g").unwrap();
+ match ki {
+ KeyInput::Sequence(keys) => {
+ assert_eq!(keys.len(), 2);
+ assert_eq!(keys[0].code, KeyCodeValue::Char('g'));
+ assert_eq!(keys[1].code, KeyCodeValue::Char('g'));
+ }
+ _ => panic!("expected sequence"),
+ }
+ }
+
+ #[test]
+ fn display_round_trip() {
+ let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"];
+ for s in cases {
+ let k = KeyInput::parse(s).unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for {s}");
+ }
+
+ let ki = KeyInput::parse("g g").unwrap();
+ assert_eq!(ki.to_string(), "g g");
+ }
+
+ #[test]
+ fn from_event_basic() {
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Enter);
+ }
+
+ #[test]
+ fn from_event_uppercase() {
+ // Crossterm sends uppercase chars with SHIFT modifier
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ // shift flag should be cleared since the case encodes it
+ assert!(!k.shift);
+ }
+
+ #[test]
+ fn from_event_matches_parsed() {
+ // Verify that from_event and parse produce the same SingleKey
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(from_event, parsed);
+
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("G").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn parse_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ // "cmd" is an alias for "super"
+ let k2 = SingleKey::parse("cmd-a").unwrap();
+ assert_eq!(k, k2);
+
+ // "win" is an alias for "super"
+ let k3 = SingleKey::parse("win-a").unwrap();
+ assert_eq!(k, k3);
+ }
+
+ #[test]
+ fn parse_super_with_other_modifiers() {
+ let k = SingleKey::parse("super-ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.super_key && k.ctrl);
+ assert!(!k.alt && !k.shift);
+ }
+
+ #[test]
+ fn display_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.to_string(), "super-a");
+
+ let k = SingleKey::parse("super-ctrl-x").unwrap();
+ assert_eq!(k.to_string(), "super-ctrl-x");
+ }
+
+ #[test]
+ fn display_round_trip_super() {
+ let k = KeyInput::parse("super-a").unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for super-a");
+ }
+
+ #[test]
+ fn from_event_super() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+ }
+
+ #[test]
+ fn from_event_super_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("super-a").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn super_uppercase_preserves_super() {
+ // super-G should keep the super flag (unlike bare "G" which clears shift)
+ let k = SingleKey::parse("super-G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(k.super_key);
+ }
+
+ #[test]
+ fn parse_errors() {
+ assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err());
+ assert!(SingleKey::parse("foobar-a").is_err());
+ }
+
+ #[test]
+ fn parse_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("F12").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+
+ let k = SingleKey::parse("ctrl-f5").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(5));
+ assert!(k.ctrl);
+
+ // F24 is valid (some keyboards have extended function keys)
+ let k = SingleKey::parse("f24").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(24));
+
+ // F0 and F25+ are invalid
+ assert!(SingleKey::parse("f0").is_err());
+ assert!(SingleKey::parse("f25").is_err());
+ }
+
+ #[test]
+ fn from_event_function_keys() {
+ let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn display_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.to_string(), "f1");
+
+ let k = SingleKey::parse("ctrl-f12").unwrap();
+ assert_eq!(k.to_string(), "ctrl-f12");
+ }
+
+ #[test]
+ fn function_key_round_trip() {
+ let cases = ["f1", "f12", "ctrl-f5", "alt-f10"];
+ for s in cases {
+ let k = KeyInput::parse(s).unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for {s}");
+ }
+ }
+
+ #[test]
+ fn from_event_function_key_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("f12").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_becomes_shift_tab() {
+ // Many terminals send BackTab for Shift+Tab
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(!k.ctrl && !k.alt);
+ }
+
+ #[test]
+ fn from_event_backtab_matches_parsed_shift_tab() {
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("shift-tab").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_with_ctrl() {
+ // BackTab with ctrl modifier
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn parse_insert_key() {
+ let k = SingleKey::parse("insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("ins").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+
+ let k = SingleKey::parse("ctrl-insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn from_event_insert_key() {
+ let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ }
+
+ #[test]
+ fn insert_key_round_trip() {
+ let k = KeyInput::parse("insert").unwrap();
+ let display = k.to_string();
+ assert_eq!(display, "insert");
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2);
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/keymap.rs b/crates/client/src/command/client/search/keybindings/keymap.rs
new file mode 100644
index 00000000..067d9403
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/keymap.rs
@@ -0,0 +1,233 @@
+use std::collections::HashMap;
+
+use super::actions::Action;
+use super::conditions::{ConditionExpr, EvalContext};
+use super::key::{KeyInput, SingleKey};
+
+/// A single rule within a keybinding: an optional condition and an action.
+/// If the condition is `None`, the rule always matches.
+#[derive(Debug, Clone)]
+pub(crate) struct KeyRule {
+ pub(crate) condition: Option<ConditionExpr>,
+ pub(crate) action: Action,
+}
+
+/// A keybinding is an ordered list of rules. The first rule whose condition
+/// matches (or has no condition) wins.
+#[derive(Debug, Clone)]
+pub(crate) struct KeyBinding {
+ pub(crate) rules: Vec<KeyRule>,
+}
+
+/// A keymap is a collection of keybindings indexed by key input.
+#[derive(Debug, Clone)]
+pub(crate) struct Keymap {
+ pub(crate) bindings: HashMap<KeyInput, KeyBinding>,
+}
+
+impl KeyRule {
+ /// Create an unconditional rule.
+ pub(crate) fn always(action: Action) -> Self {
+ Self {
+ condition: None,
+ action,
+ }
+ }
+
+ /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
+ /// including bare `ConditionAtom` values.
+ pub(crate) fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
+ Self {
+ condition: Some(condition.into()),
+ action,
+ }
+ }
+}
+
+impl KeyBinding {
+ /// Create a simple (unconditional) binding.
+ pub(crate) fn simple(action: Action) -> Self {
+ Self {
+ rules: vec![KeyRule::always(action)],
+ }
+ }
+
+ /// Create a conditional binding from a list of rules.
+ pub(crate) fn conditional(rules: Vec<KeyRule>) -> Self {
+ Self { rules }
+ }
+}
+
+impl Keymap {
+ /// Create an empty keymap.
+ pub(crate) fn new() -> Self {
+ Self {
+ bindings: HashMap::new(),
+ }
+ }
+
+ /// Bind a key input to a simple (unconditional) action.
+ pub(crate) fn bind(&mut self, key: KeyInput, action: Action) {
+ self.bindings.insert(key, KeyBinding::simple(action));
+ }
+
+ /// Bind a key input to a conditional set of rules.
+ pub(crate) fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {
+ self.bindings.insert(key, KeyBinding::conditional(rules));
+ }
+
+ /// Resolve a key input to an action given the current evaluation context.
+ /// Returns `None` if the key has no binding or no rule's condition matches.
+ pub(crate) fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {
+ let binding = self.bindings.get(key)?;
+ for rule in &binding.rules {
+ match &rule.condition {
+ None => return Some(rule.action.clone()),
+ Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),
+ Some(_) => {}
+ }
+ }
+ None
+ }
+
+ /// Check if any binding starts with the given single key as the first key
+ /// of a multi-key sequence. Used to detect pending multi-key sequences.
+ pub(crate) fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {
+ self.bindings.keys().any(|ki| match ki {
+ KeyInput::Sequence(keys) => keys.first() == Some(prefix),
+ KeyInput::Single(_) => false,
+ })
+ }
+
+ /// Merge another keymap into this one. Keys from `other` override keys in `self`.
+ #[expect(dead_code)]
+ pub(crate) fn merge(&mut self, other: &Self) {
+ for (key, binding) in &other.bindings {
+ self.bindings.insert(key.clone(), binding.clone());
+ }
+ }
+}
+
+impl Default for Keymap {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::conditions::ConditionAtom;
+ use super::{Action, EvalContext, KeyInput, KeyRule, Keymap, SingleKey};
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ #[test]
+ fn simple_binding_resolves() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ keymap.bind(key.clone(), Action::ReturnOriginal);
+
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));
+ }
+
+ #[test]
+ fn conditional_first_match_wins() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ keymap.bind_conditional(
+ key.clone(),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+
+ // Cursor at start → Exit
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));
+
+ // Cursor not at start → CursorLeft
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn no_match_returns_none() {
+ let keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn conditional_no_condition_matches_returns_none() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ // Only one rule with a condition that won't match
+ keymap.bind_conditional(
+ key.clone(),
+ vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],
+ );
+
+ // Cursor not at start → no match
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn has_sequence_starting_with() {
+ let mut keymap = Keymap::new();
+ let seq = KeyInput::parse("g g").unwrap();
+ keymap.bind(seq, Action::ScrollToTop);
+
+ let g = SingleKey::parse("g").unwrap();
+ assert!(keymap.has_sequence_starting_with(&g));
+
+ let h = SingleKey::parse("h").unwrap();
+ assert!(!keymap.has_sequence_starting_with(&h));
+ }
+
+ #[test]
+ fn merge_overrides() {
+ let mut base = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ base.bind(key.clone(), Action::ReturnOriginal);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn merge_preserves_unoverridden() {
+ let mut base = Keymap::new();
+ let key1 = KeyInput::parse("ctrl-c").unwrap();
+ let key2 = KeyInput::parse("ctrl-d").unwrap();
+ base.bind(key1.clone(), Action::ReturnOriginal);
+ base.bind(key2.clone(), Action::DeleteCharAfter);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key1.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));
+ assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));
+ }
+}
diff --git a/crates/client/src/command/client/search/keybindings/mod.rs b/crates/client/src/command/client/search/keybindings/mod.rs
new file mode 100644
index 00000000..cdca0406
--- /dev/null
+++ b/crates/client/src/command/client/search/keybindings/mod.rs
@@ -0,0 +1,14 @@
+pub(crate) mod actions;
+pub(crate) mod conditions;
+pub(crate) mod defaults;
+pub(crate) mod key;
+pub(crate) mod keymap;
+
+pub(crate) use actions::Action;
+#[expect(unused_imports)]
+pub(crate) use conditions::{ConditionAtom, ConditionExpr, EvalContext};
+pub(crate) use defaults::KeymapSet;
+#[expect(unused_imports)]
+pub(crate) use key::{KeyCodeValue, KeyInput, SingleKey};
+#[expect(unused_imports)]
+pub(crate) use keymap::{KeyBinding, KeyRule, Keymap};
diff --git a/crates/client/src/command/client/server.rs b/crates/client/src/command/client/server.rs
new file mode 100644
index 00000000..88b12a7a
--- /dev/null
+++ b/crates/client/src/command/client/server.rs
@@ -0,0 +1,56 @@
+use std::net::SocketAddr;
+
+use crate::atuin_server::{Settings, database::DbType, launch, launch_metrics_server};
+
+use clap::Subcommand;
+use eyre::{Context, Result, eyre};
+
+#[derive(Subcommand, Clone, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Start the server
+ Start {
+ /// The host address to bind
+ #[clap(long)]
+ host: Option<String>,
+
+ /// The port to bind
+ #[clap(long, short)]
+ port: Option<u16>,
+ },
+
+ /// Print server example configuration
+ DefaultConfig,
+}
+
+impl Cmd {
+ pub(crate) async fn run(self) -> Result<()> {
+ match self {
+ Self::Start { host, port } => {
+ let settings = Settings::new().wrap_err("could not load server settings")?;
+ let host = host.as_ref().unwrap_or(&settings.host).clone();
+ let port = port.unwrap_or(settings.port);
+ let addr = SocketAddr::new(host.parse()?, port);
+
+ if settings.metrics.enable {
+ tokio::spawn(launch_metrics_server(
+ settings.metrics.host.clone(),
+ settings.metrics.port,
+ ));
+ }
+
+ match settings.db_settings.db_type() {
+ DbType::Postgres => launch(settings, addr).await,
+ DbType::Unknown => {
+ Err(eyre!("db_uri must start with postgres:// or sqlite://"))
+ }
+ }
+ }
+ Self::DefaultConfig => {
+ // TODO(@bpeetz): Add this back <2026-06-11>
+ println!("TODO");
+ Ok(())
+ }
+ }
+ }
+}
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
new file mode 100644
index 00000000..9ea5e283
--- /dev/null
+++ b/crates/client/src/command/client/stats.rs
@@ -0,0 +1,82 @@
+use clap::Parser;
+use eyre::Result;
+use interim::parse_date_string;
+use time::{Duration, OffsetDateTime, Time};
+
+use crate::atuin_client::database::ClientSqlite;
+use crate::atuin_client::{database::current_context, settings::Settings};
+
+use crate::atuin_history::stats::{compute, pretty_print};
+
+fn parse_ngram_size(s: &str) -> Result<usize, String> {
+ let value = s
+ .parse::<usize>()
+ .map_err(|_| format!("'{s}' is not a valid window size"))?;
+
+ if value == 0 {
+ return Err("ngram window size must be at least 1".to_string());
+ }
+
+ Ok(value)
+}
+
+#[derive(Parser, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) struct Cmd {
+ /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details.
+ period: Vec<String>,
+
+ /// How many top commands to list
+ #[arg(long, short, default_value = "10")]
+ count: usize,
+
+ /// The number of consecutive commands to consider
+ #[arg(long, short, default_value = "1", value_parser = parse_ngram_size)]
+ ngram_size: usize,
+}
+
+impl Cmd {
+ pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> {
+ let context = current_context().await?;
+ let words = if self.period.is_empty() {
+ String::from("all")
+ } else {
+ self.period.join(" ")
+ };
+
+ let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
+ let last_night = now.replace_time(Time::MIDNIGHT);
+
+ let history = if words.as_str() == "all" {
+ db.list(&[], &context, None, false, false).await?
+ } else if words.trim() == "today" {
+ let start = last_night;
+ let end = start + Duration::days(1);
+ db.range(start, end).await?
+ } else if words.trim() == "month" {
+ let end = last_night;
+ let start = end - Duration::days(31);
+ db.range(start, end).await?
+ } else if words.trim() == "week" {
+ let end = last_night;
+ let start = end - Duration::days(7);
+ db.range(start, end).await?
+ } else if words.trim() == "year" {
+ let end = last_night;
+ let start = end - Duration::days(365);
+ db.range(start, end).await?
+ } else {
+ let start = parse_date_string(&words, now, settings.dialect.into())?;
+ let end = start + Duration::days(1);
+ db.range(start, end).await?
+ };
+
+ let stats = compute(settings, &history, self.count, self.ngram_size);
+
+ if let Some(stats) = stats {
+ pretty_print(stats, self.ngram_size);
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store.rs b/crates/client/src/command/client/store.rs
new file mode 100644
index 00000000..bc57488d
--- /dev/null
+++ b/crates/client/src/command/client/store.rs
@@ -0,0 +1,108 @@
+use clap::Subcommand;
+use eyre::Result;
+
+use crate::atuin_client::{
+ database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
+};
+use itertools::Itertools;
+use time::{OffsetDateTime, UtcOffset};
+
+mod pull;
+mod purge;
+mod push;
+mod rebuild;
+mod rekey;
+mod verify;
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Print the current status of the record store
+ Status,
+
+ /// Rebuild a store (eg atuin store rebuild history)
+ Rebuild(rebuild::Rebuild),
+
+ /// Re-encrypt the store with a new key (potential for data loss!)
+ Rekey(rekey::Rekey),
+
+ /// Delete all records in the store that cannot be decrypted with the current key
+ Purge(purge::Purge),
+
+ /// Verify that all records in the store can be decrypted with the current key
+ Verify(verify::Verify),
+
+ /// Push all records to the remote sync server (one way sync)
+ Push(push::Push),
+
+ /// Pull records from the remote sync server (one way sync)
+ Pull(pull::Pull),
+}
+
+impl Cmd {
+ pub(crate) async fn run(
+ &self,
+ settings: &Settings,
+ database: &ClientSqlite,
+ store: SqliteStore,
+ ) -> Result<()> {
+ match self {
+ Self::Status => self.status(store).await,
+ Self::Rebuild(rebuild) => rebuild.run(settings, store, database).await,
+ Self::Rekey(rekey) => rekey.run(settings, store).await,
+ Self::Verify(verify) => verify.run(settings, store).await,
+ Self::Purge(purge) => purge.run(settings, store).await,
+ Self::Push(push) => push.run(settings, store).await,
+ Self::Pull(pull) => pull.run(settings, store, database).await,
+ }
+ }
+
+ pub(crate) async fn status(&self, store: SqliteStore) -> Result<()> {
+ let host_id = Settings::host_id().await?;
+ let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+
+ let status = store.status().await?;
+
+ // TODO: should probs build some data structure and then pretty-print it or smth
+ for (host, st) in status.hosts.iter().sorted_by_key(|(h, _)| *h) {
+ let host_string = if host == &host_id {
+ format!("host: {} <- CURRENT HOST", host.0.as_hyphenated())
+ } else {
+ format!("host: {}", host.0.as_hyphenated())
+ };
+
+ println!("{host_string}");
+
+ for (tag, idx) in st.iter().sorted_by_key(|(tag, _)| *tag) {
+ println!("\tstore: {tag}");
+
+ let first = store.first(*host, tag).await?;
+ let last = store.last(*host, tag).await?;
+
+ println!("\t\tidx: {idx}");
+
+ if let Some(first) = first {
+ println!("\t\tfirst: {}", first.id.0.as_hyphenated());
+
+ let time =
+ OffsetDateTime::from_unix_timestamp_nanos(i128::from(first.timestamp))?
+ .to_offset(offset);
+ println!("\t\t\tcreated: {time}");
+ }
+
+ if let Some(last) = last {
+ println!("\t\tlast: {}", last.id.0.as_hyphenated());
+
+ let time =
+ OffsetDateTime::from_unix_timestamp_nanos(i128::from(last.timestamp))?
+ .to_offset(offset);
+ println!("\t\t\tcreated: {time}");
+ }
+ }
+
+ println!();
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/pull.rs b/crates/client/src/command/client/store/pull.rs
new file mode 100644
index 00000000..3a0865be
--- /dev/null
+++ b/crates/client/src/command/client/store/pull.rs
@@ -0,0 +1,95 @@
+use clap::Args;
+use eyre::Result;
+
+use crate::atuin_client::{
+ database::ClientSqlite,
+ encryption::load_key,
+ record::{
+ sqlite_store::SqliteStore,
+ sync::{self, Operation},
+ },
+ settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Pull {
+ /// The tag to push (eg, 'history'). Defaults to all tags
+ #[arg(long, short)]
+ pub(crate) tag: Option<String>,
+
+ /// Force push records
+ /// This will first wipe the local store, and then download all records from the remote
+ #[arg(long, default_value = "false")]
+ pub(crate) force: bool,
+
+ /// Page Size
+ /// How many records to download at once. Defaults to 100
+ #[arg(long, default_value = "100")]
+ pub(crate) page: u64,
+}
+
+impl Pull {
+ pub(crate) async fn run(
+ &self,
+ settings: &Settings,
+ store: SqliteStore,
+ db: &ClientSqlite,
+ ) -> Result<()> {
+ if self.force {
+ println!("Forcing local overwrite!");
+ println!("Clearing local store");
+
+ store.delete_all().await?;
+ }
+
+ // We can actually just use the existing diff/etc to push
+ // 1. Diff
+ // 2. Get operations
+ // 3. Filter operations by
+ // a) are they a download op?
+ // b) are they for the host/tag we are pushing here?
+ let client = sync::build_client(settings)?;
+ let (diff, remote_index) = sync::diff(&client, &store).await?;
+
+ // Skip on --force: local was already wiped above, mismatch is the user's call.
+ if !self.force {
+ let key: [u8; 32] = load_key(settings)?.into();
+ sync::check_encryption_key(&client, &remote_index, &key)
+ .await
+ .map_err(crate::print_error::format_sync_error)?;
+ }
+
+ let operations = sync::operations(diff, &store)?;
+
+ let operations = operations
+ .into_iter()
+ .filter(|op| match op {
+ // No noops or downloads thx
+ Operation::Noop { .. } | Operation::Upload { .. } => false,
+
+ // pull, so yes plz to downloads!
+ Operation::Download { tag, .. } => {
+ if self.force {
+ return true;
+ }
+
+ if let Some(t) = self.tag.clone()
+ && t != *tag
+ {
+ return false;
+ }
+
+ true
+ }
+ })
+ .collect();
+
+ let (_, downloaded) = sync::sync_remote(&client, operations, &store, self.page).await?;
+
+ println!("Downloaded {} records", downloaded.len());
+
+ crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/purge.rs b/crates/client/src/command/client/store/purge.rs
new file mode 100644
index 00000000..a23f1886
--- /dev/null
+++ b/crates/client/src/command/client/store/purge.rs
@@ -0,0 +1,24 @@
+use clap::Args;
+use eyre::Result;
+
+use crate::atuin_client::{
+ encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Purge {}
+
+impl Purge {
+ pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
+ println!("Purging local records that cannot be decrypted");
+
+ let key = load_key(settings)?;
+
+ match store.purge(&key.into()).await {
+ Ok(()) => println!("Local store purge completed OK"),
+ Err(e) => println!("Failed to purge local store: {e:?}"),
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/push.rs b/crates/client/src/command/client/store/push.rs
new file mode 100644
index 00000000..9d66b5b2
--- /dev/null
+++ b/crates/client/src/command/client/store/push.rs
@@ -0,0 +1,113 @@
+use crate::atuin_common::record::HostId;
+use clap::Args;
+use eyre::{OptionExt, Result};
+use uuid::Uuid;
+
+use crate::atuin_client::{
+ api_client::Client,
+ encryption::load_key,
+ record::sync::Operation,
+ record::{sqlite_store::SqliteStore, sync},
+ settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Push {
+ /// The tag to push (eg, 'history'). Defaults to all tags
+ #[arg(long, short)]
+ pub(crate) tag: Option<String>,
+
+ /// The host to push, in the form of a UUID host ID. Defaults to the current host.
+ #[arg(long)]
+ pub(crate) host: Option<Uuid>,
+
+ /// Force push records
+ /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the
+ /// local store
+ #[arg(long, default_value = "false")]
+ pub(crate) force: bool,
+
+ /// Page Size
+ /// How many records to upload at once. Defaults to 100
+ #[arg(long, default_value = "100")]
+ pub(crate) page: u64,
+}
+
+impl Push {
+ pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
+ let host_id = Settings::host_id().await?;
+
+ if self.force {
+ println!("Forcing remote store overwrite!");
+ println!("Clearing remote store");
+
+ let client = Client::new(
+ &settings.sync.address,
+ settings.network_connect_timeout,
+ // we may be deleting a lot of data... so increase the
+ // timeout
+ settings.network_timeout * 10,
+ settings.sync.user_id()?.ok_or_eyre("no sync user-id")?,
+ )
+ .expect("failed to create client");
+
+ client.delete_store().await?;
+ }
+
+ // We can actually just use the existing diff/etc to push
+ // 1. Diff
+ // 2. Get operations
+ // 3. Filter operations by
+ // a) are they an upload op?
+ // b) are they for the host/tag we are pushing here?
+ let client = sync::build_client(settings)?;
+ let (diff, remote_index) = sync::diff(&client, &store).await?;
+
+ // Skip on --force: that path intentionally replaces remote with local.
+ if !self.force {
+ let key: [u8; 32] = load_key(settings)?.into();
+ sync::check_encryption_key(&client, &remote_index, &key)
+ .await
+ .map_err(crate::print_error::format_sync_error)?;
+ }
+
+ let operations = sync::operations(diff, &store)?;
+
+ let operations = operations
+ .into_iter()
+ .filter(|op| match op {
+ // No noops or downloads thx
+ Operation::Noop { .. } | Operation::Download { .. } => false,
+
+ // push, so yes plz to uploads!
+ Operation::Upload { host, tag, .. } => {
+ if self.force {
+ return true;
+ }
+
+ if let Some(h) = self.host {
+ if HostId(h) != *host {
+ return false;
+ }
+ } else if *host != host_id {
+ return false;
+ }
+
+ if let Some(t) = self.tag.clone()
+ && t != *tag
+ {
+ return false;
+ }
+
+ true
+ }
+ })
+ .collect();
+
+ let (uploaded, _) = sync::sync_remote(&client, operations, &store, self.page).await?;
+
+ println!("Uploaded {uploaded} records");
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/rebuild.rs b/crates/client/src/command/client/store/rebuild.rs
new file mode 100644
index 00000000..6be67cd0
--- /dev/null
+++ b/crates/client/src/command/client/store/rebuild.rs
@@ -0,0 +1,56 @@
+use clap::Args;
+use eyre::{Result, bail};
+
+use crate::command::client::daemon as daemon_cmd;
+
+use crate::atuin_client::{
+ database::ClientSqlite, encryption, history::store::HistoryStore,
+ record::sqlite_store::SqliteStore, settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Rebuild {
+ pub(crate) tag: String,
+}
+
+impl Rebuild {
+ pub(crate) async fn run(
+ &self,
+ settings: &Settings,
+ store: SqliteStore,
+ database: &ClientSqlite,
+ ) -> Result<()> {
+ // keep it as a string and not an enum atm
+ // would be super cool to build this dynamically in the future
+ // eg register handles for rebuilding various tags without having to make this part of the
+ // binary big
+ match self.tag.as_str() {
+ "history" => {
+ self.rebuild_history(settings, store.clone(), database)
+ .await?;
+ }
+
+ tag => bail!("unknown tag: {tag}"),
+ }
+
+ Ok(())
+ }
+
+ async fn rebuild_history(
+ &self,
+ settings: &Settings,
+ store: SqliteStore,
+ database: &ClientSqlite,
+ ) -> Result<()> {
+ let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();
+
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store, host_id, encryption_key);
+
+ history_store.build(database).await?;
+
+ daemon_cmd::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryRebuilt).await;
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/rekey.rs b/crates/client/src/command/client/store/rekey.rs
new file mode 100644
index 00000000..2b379327
--- /dev/null
+++ b/crates/client/src/command/client/store/rekey.rs
@@ -0,0 +1,46 @@
+use clap::Args;
+use eyre::Result;
+use tokio::{fs::File, io::AsyncWriteExt};
+
+use crate::atuin_client::{
+ encryption::{decode_key, generate_encoded_key, load_key},
+ record::sqlite_store::SqliteStore,
+ settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Rekey {
+ /// The new key to use for encryption. Omit for a randomly-generated key
+ key: Option<String>,
+}
+
+impl Rekey {
+ pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
+ let key = if let Some(key) = self.key.clone() {
+ println!("Re-encrypting store with specified key");
+
+ key
+ } else {
+ println!("Re-encrypting store with freshly-generated key");
+ let (_, encoded) = generate_encoded_key()?;
+ encoded
+ };
+
+ let current_key: [u8; 32] = load_key(settings)?.into();
+ let new_key: [u8; 32] = decode_key(&key)?.into();
+
+ store.re_encrypt(&current_key, &new_key).await?;
+
+ if let Some(key_path) = settings.sync.encryption_key_path.as_ref() {
+ println!("Store rewritten. Saving new key");
+ let mut file = File::create(key_path).await?;
+ file.write_all(key.as_bytes()).await?;
+ } else {
+ println!(
+ "No key-path (settings.sync.encryption_key_path) set in config, will not save new key."
+ );
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/store/verify.rs b/crates/client/src/command/client/store/verify.rs
new file mode 100644
index 00000000..a39227f9
--- /dev/null
+++ b/crates/client/src/command/client/store/verify.rs
@@ -0,0 +1,24 @@
+use clap::Args;
+use eyre::Result;
+
+use crate::atuin_client::{
+ encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings,
+};
+
+#[derive(Args, Debug)]
+pub(crate) struct Verify {}
+
+impl Verify {
+ pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
+ println!("Verifying local store can be decrypted with the current key");
+
+ let key = load_key(settings)?;
+
+ match store.verify(&key.into()).await {
+ Ok(()) => println!("Local store encryption verified OK"),
+ Err(e) => println!("Failed to verify local store encryption: {e:?}"),
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs
new file mode 100644
index 00000000..c29a82fc
--- /dev/null
+++ b/crates/client/src/command/client/sync.rs
@@ -0,0 +1,120 @@
+use clap::Subcommand;
+use eyre::{Result, WrapErr};
+use serde_json::json;
+
+use crate::{
+ atuin_client::{
+ database::ClientSqlite,
+ encryption,
+ history::store::HistoryStore,
+ record::{sqlite_store::SqliteStore, sync},
+ settings::Settings,
+ },
+ atuin_common::utils,
+};
+
+mod status;
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Sync with the configured server
+ Perform {
+ /// Force re-download everything
+ #[arg(long, short)]
+ force: bool,
+ },
+
+ /// Print (or generate) the encryption key and user id for transfer to another machine
+ KeyAndId {},
+
+ /// Display the sync status
+ Status,
+}
+
+impl Cmd {
+ pub(crate) async fn run(
+ self,
+ settings: Settings,
+ db: &ClientSqlite,
+ store: SqliteStore,
+ ) -> Result<()> {
+ match self {
+ Self::Perform { force } => run(&settings, force, db, store).await,
+ Self::Status => status::run(&settings).await,
+ Self::KeyAndId {} => {
+ use crate::atuin_client::encryption::{encode_key, load_key};
+
+ let key = load_key(&settings).wrap_err("could not load encryption key")?;
+ let user_id = settings
+ .sync
+ .user_id()
+ .wrap_err("Failed to load user-id")?
+ .unwrap_or_else(utils::uuid_v7);
+
+ let key = encode_key(&key).wrap_err("could not encode encryption key")?;
+
+ let json = serde_json::to_string_pretty(&json!({ "key": key, "user_id": user_id }))
+ .expect("Will always be formattable");
+
+ println!("{json}");
+
+ Ok(())
+ }
+ }
+ }
+}
+
+async fn run(
+ settings: &Settings,
+ force: bool,
+ db: &ClientSqlite,
+ store: SqliteStore,
+) -> Result<()> {
+ let encryption_key: [u8; 32] = encryption::load_key(settings)
+ .context("could not load encryption key")?
+ .into();
+
+ let host_id = Settings::host_id().await?;
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
+ .await
+ .map_err(crate::print_error::format_sync_error)?;
+
+ crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
+
+ println!("{uploaded}/{} up/down to record store", downloaded.len());
+
+ let history_length = db.history_count(true).await?;
+ let store_history_length = store.len_tag("history").await?;
+
+ #[expect(clippy::cast_sign_loss)]
+ if history_length as u64 > store_history_length {
+ println!("{history_length} in history index, but {store_history_length} in history store");
+ println!("Running automatic history store init...");
+
+ // Internally we use the global filter mode, so this context is ignored.
+ // don't recurse or loop here.
+ history_store.init_store(db).await?;
+
+ println!("Re-running sync due to new records locally");
+
+ // we'll want to run sync once more, as there will now be stuff to upload
+ let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
+ .await
+ .map_err(crate::print_error::format_sync_error)?;
+
+ crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
+
+ println!("{uploaded}/{} up/down to record store", downloaded.len());
+ }
+
+ println!(
+ "Sync complete! {} items in history database, force: {}",
+ db.history_count(true).await?,
+ force
+ );
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/sync/status.rs b/crates/client/src/command/client/sync/status.rs
new file mode 100644
index 00000000..caf3b90f
--- /dev/null
+++ b/crates/client/src/command/client/sync/status.rs
@@ -0,0 +1,25 @@
+use crate::atuin_client::settings::Settings;
+use crate::{SHA, VERSION};
+use colored::Colorize;
+use eyre::{Result, bail};
+
+pub(crate) async fn run(settings: &Settings) -> Result<()> {
+ if let Some(me) = settings.sync.user_id()? {
+ let last_sync = Settings::last_sync().await?;
+
+ println!("Atuin v{VERSION} - Build rev {SHA}\n");
+
+ println!("{}", "[Local]".green());
+ println!("Sync frequency: {}", settings.sync.frequency);
+ println!("Last sync: {}", last_sync.to_offset(settings.timezone.0));
+ println!("Auto sync: {}", settings.sync.auto);
+
+ println!("{}", "[Remote]".green());
+ println!("Address: {}", settings.sync.address);
+ println!("User id: {me}");
+ } else {
+ bail!("You are not logged in to a sync server - cannot show sync status");
+ }
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs
new file mode 100644
index 00000000..2ce19bf7
--- /dev/null
+++ b/crates/client/src/command/client/wrapped.rs
@@ -0,0 +1,327 @@
+use crossterm::style::{ResetColor, SetAttribute};
+use eyre::Result;
+use std::collections::{HashMap, HashSet};
+use time::{Date, Duration, Month, OffsetDateTime, Time};
+
+use crate::atuin_client::database::ClientSqlite;
+use crate::atuin_client::settings::Settings;
+
+use crate::atuin_history::stats::{Stats, compute};
+
+#[derive(Debug)]
+struct WrappedStats {
+ nav_commands: usize,
+ pkg_commands: usize,
+ error_rate: f64,
+ first_half_commands: Vec<(String, usize)>,
+ second_half_commands: Vec<(String, usize)>,
+ git_percentage: f64,
+ busiest_hour: Option<(String, usize)>,
+}
+
+impl WrappedStats {
+ #[expect(clippy::too_many_lines, clippy::cast_precision_loss)]
+ fn new(
+ settings: &Settings,
+ stats: &Stats,
+ history: &[crate::atuin_client::history::History],
+ ) -> Self {
+ let nav_commands = stats
+ .top
+ .iter()
+ .filter(|(cmd, _)| {
+ let cmd = &cmd[0];
+ cmd == "cd"
+ || cmd == "ls"
+ || cmd == "ll"
+ || cmd == "pwd"
+ || cmd == "pushd"
+ || cmd == "popd"
+ })
+ .map(|(_, count)| count)
+ .sum();
+
+ let pkg_managers = [
+ "cargo",
+ "npm",
+ "pnpm",
+ "yarn",
+ "pip",
+ "pip3",
+ "pipenv",
+ "poetry",
+ "pipx",
+ "uv",
+ "brew",
+ "apt",
+ "apt-get",
+ "apk",
+ "pacman",
+ "yay",
+ "paru",
+ "yum",
+ "dnf",
+ "dnf5",
+ "rpm",
+ "rpm-ostree",
+ "zypper",
+ "pkg",
+ "chocolatey",
+ "choco",
+ "scoop",
+ "winget",
+ "gem",
+ "bundle",
+ "shards",
+ "composer",
+ "gradle",
+ "maven",
+ "mvn",
+ "go get",
+ "nuget",
+ "dotnet",
+ "mix",
+ "hex",
+ "rebar3",
+ "nix",
+ "nix-env",
+ "cabal",
+ "opam",
+ ];
+
+ let pkg_commands = history
+ .iter()
+ .filter(|h| {
+ let cmd = h.command.clone();
+ pkg_managers.iter().any(|pm| cmd.starts_with(pm))
+ })
+ .count();
+
+ // Error analysis
+ let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); // (total_uses, errors)
+ let midyear = history[0].timestamp + Duration::days(182); // Split year in half
+
+ let mut first_half_commands: HashMap<String, usize> = HashMap::new();
+ let mut second_half_commands: HashMap<String, usize> = HashMap::new();
+ let mut hours: HashMap<String, usize> = HashMap::new();
+
+ for entry in history {
+ let cmd = entry
+ .command
+ .split_whitespace()
+ .next()
+ .unwrap_or("")
+ .to_string();
+ let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0));
+ *total += 1;
+ if entry.exit != 0 {
+ *errors += 1;
+ }
+
+ // Track command evolution
+ if entry.timestamp < midyear {
+ *first_half_commands.entry(cmd.clone()).or_default() += 1;
+ } else {
+ *second_half_commands.entry(cmd).or_default() += 1;
+ }
+
+ // Track hourly distribution
+ let local_time = entry
+ .timestamp
+ .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0));
+ let hour = format!("{:02}:00", local_time.time().hour());
+ *hours.entry(hour).or_default() += 1;
+ }
+
+ let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum();
+ let total_commands: usize = command_errors.values().map(|(total, _)| total).sum();
+ let error_rate = total_errors as f64 / total_commands as f64;
+
+ // Process command evolution data
+ let mut first_half: Vec<_> = first_half_commands.into_iter().collect();
+ let mut second_half: Vec<_> = second_half_commands.into_iter().collect();
+ first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
+ second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
+ first_half.truncate(5);
+ second_half.truncate(5);
+
+ // Calculate git percentage
+ let git_commands: usize = stats
+ .top
+ .iter()
+ .filter(|(cmd, _)| cmd[0].starts_with("git"))
+ .map(|(_, count)| count)
+ .sum();
+ let git_percentage = git_commands as f64 / stats.total_commands as f64;
+
+ // Find busiest hour
+ let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count);
+
+ Self {
+ nav_commands,
+ pkg_commands,
+ error_rate,
+ first_half_commands: first_half,
+ second_half_commands: second_half,
+ git_percentage,
+ busiest_hour,
+ }
+ }
+}
+
+pub(crate) fn print_wrapped_header(year: i32) {
+ let reset = ResetColor;
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ println!("{bold}╭────────────────────────────────────╮{reset}");
+ println!("{bold}│ ATUIN WRAPPED {year} │{reset}");
+ println!("{bold}│ Your Year in Shell History │{reset}");
+ println!("{bold}╰────────────────────────────────────╯{reset}");
+ println!();
+}
+
+#[expect(clippy::cast_precision_loss)]
+fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
+ let reset = ResetColor;
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ if wrapped_stats.git_percentage > 0.05 {
+ println!(
+ "{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\n",
+ wrapped_stats.git_percentage * 100.0
+ );
+ }
+ // Navigation patterns
+ let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0;
+ if nav_percentage > 0.05 {
+ println!(
+ "{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\n",
+ );
+ }
+
+ // Command vocabulary
+ println!(
+ "{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n",
+ stats.unique_commands
+ );
+
+ // Package management
+ println!(
+ "{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n",
+ wrapped_stats.pkg_commands
+ );
+
+ // Error patterns
+ let error_percentage = wrapped_stats.error_rate * 100.0;
+ println!(
+ "{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\n",
+ );
+
+ // Command evolution
+ println!("🔍 Command Evolution:");
+
+ // print stats for each half and compare
+ println!(" {bold}Top Commands{reset} in the first half of {year}:");
+ for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+
+ println!(" {bold}Top Commands{reset} in the second half of {year}:");
+ for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+
+ // Find new favorite commands (in top 5 of second half but not in first half)
+ let first_half_set: HashSet<_> = wrapped_stats
+ .first_half_commands
+ .iter()
+ .map(|(cmd, _)| cmd)
+ .collect();
+ let new_favorites: Vec<_> = wrapped_stats
+ .second_half_commands
+ .iter()
+ .filter(|(cmd, _)| !first_half_set.contains(cmd))
+ .take(2)
+ .collect();
+
+ if !new_favorites.is_empty() {
+ println!(" {bold}New favorites{reset} in the second half:");
+ for (cmd, count) in new_favorites {
+ println!(" {bold}{cmd}{reset} ({count} times)");
+ }
+ }
+
+ // Time patterns
+ if let Some((hour, count)) = &wrapped_stats.busiest_hour {
+ println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)");
+
+ // Night owl or early bird
+ let hour_num = hour
+ .split(':')
+ .next()
+ .unwrap_or("0")
+ .parse::<u32>()
+ .unwrap_or(0);
+ if hour_num >= 22 || hour_num <= 4 {
+ println!(" You're quite the night owl! 🦉");
+ } else if (5..=7).contains(&hour_num) {
+ println!(" Early bird gets the worm! 🐦");
+ }
+ }
+
+ println!();
+}
+
+pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> {
+ let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
+ let month = now.month();
+
+ // If we're in December, then wrapped is for the current year. If not, it's for the previous year
+ let year = year.unwrap_or_else(|| {
+ if month == Month::December {
+ now.year()
+ } else {
+ now.year() - 1
+ }
+ });
+
+ let start = OffsetDateTime::new_in_offset(
+ Date::from_calendar_date(year, Month::January, 1).unwrap(),
+ Time::MIDNIGHT,
+ now.offset(),
+ );
+ let end = OffsetDateTime::new_in_offset(
+ Date::from_calendar_date(year, Month::December, 31).unwrap(),
+ Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1),
+ now.offset(),
+ );
+
+ let history = db.range(start, end).await?;
+ if history.is_empty() {
+ println!(
+ "Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄"
+ );
+ return Ok(());
+ }
+
+ // Compute overall stats using existing functionality
+ let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats");
+ let wrapped_stats = WrappedStats::new(settings, &stats, &history);
+
+ // Print wrapped format
+ print_wrapped_header(year);
+
+ println!("🎉 In {year}, you typed {} commands!", stats.total_commands);
+ println!(
+ " That's ~{} commands every day\n",
+ stats.total_commands / 365
+ );
+
+ println!("Your Top Commands:");
+ crate::atuin_history::stats::pretty_print(stats.clone(), 1);
+ println!();
+
+ print_fun_facts(&wrapped_stats, &stats, year);
+
+ Ok(())
+}
diff --git a/crates/client/src/command/contributors.rs b/crates/client/src/command/contributors.rs
new file mode 100644
index 00000000..b2a41522
--- /dev/null
+++ b/crates/client/src/command/contributors.rs
@@ -0,0 +1,5 @@
+static CONTRIBUTORS: &str = include_str!("CONTRIBUTORS");
+
+pub(crate) fn run() {
+ println!("\n{CONTRIBUTORS}");
+}
diff --git a/crates/client/src/command/gen_completions.rs b/crates/client/src/command/gen_completions.rs
new file mode 100644
index 00000000..9f13bffc
--- /dev/null
+++ b/crates/client/src/command/gen_completions.rs
@@ -0,0 +1,84 @@
+use clap::{CommandFactory, Parser, ValueEnum};
+use clap_complete::{Generator, Shell, generate, generate_to};
+use clap_complete_nushell::Nushell;
+use eyre::Result;
+
+// clap put nushell completions into a separate package due to the maintainers
+// being a little less committed to support them.
+// This means we have to do a tiny bit of legwork to combine these completions
+// into one command.
+#[derive(Debug, Clone, ValueEnum)]
+#[value(rename_all = "lower")]
+pub(crate) enum GenShell {
+ Bash,
+ Elvish,
+ Fish,
+ Nushell,
+ PowerShell,
+ Zsh,
+}
+
+impl Generator for GenShell {
+ fn file_name(&self, name: &str) -> String {
+ match self {
+ // clap_complete
+ Self::Bash => Shell::Bash.file_name(name),
+ Self::Elvish => Shell::Elvish.file_name(name),
+ Self::Fish => Shell::Fish.file_name(name),
+ Self::PowerShell => Shell::PowerShell.file_name(name),
+ Self::Zsh => Shell::Zsh.file_name(name),
+
+ // clap_complete_nushell
+ Self::Nushell => Nushell.file_name(name),
+ }
+ }
+
+ fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) {
+ match self {
+ // clap_complete
+ Self::Bash => Shell::Bash.generate(cmd, buf),
+ Self::Elvish => Shell::Elvish.generate(cmd, buf),
+ Self::Fish => Shell::Fish.generate(cmd, buf),
+ Self::PowerShell => Shell::PowerShell.generate(cmd, buf),
+ Self::Zsh => Shell::Zsh.generate(cmd, buf),
+
+ // clap_complete_nushell
+ Self::Nushell => Nushell.generate(cmd, buf),
+ }
+ }
+}
+
+#[derive(Debug, Parser)]
+pub(crate) struct Cmd {
+ /// Set the shell for generating completions
+ #[arg(long, short)]
+ shell: GenShell,
+
+ /// Set the output directory
+ #[arg(long, short)]
+ out_dir: Option<String>,
+}
+
+impl Cmd {
+ pub(crate) fn run(self) -> Result<()> {
+ let Self { shell, out_dir } = self;
+
+ let mut cli = crate::Atuin::command();
+
+ match out_dir {
+ Some(out_dir) => {
+ generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?;
+ }
+ None => {
+ generate(
+ shell,
+ &mut cli,
+ env!("CARGO_PKG_NAME"),
+ &mut std::io::stdout(),
+ );
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs
new file mode 100644
index 00000000..9a648254
--- /dev/null
+++ b/crates/client/src/command/mod.rs
@@ -0,0 +1,141 @@
+use clap::Subcommand;
+use eyre::Result;
+
+#[cfg(not(windows))]
+use rustix::{fs::Mode, process::umask};
+
+mod client;
+mod contributors;
+mod gen_completions;
+
+#[derive(Subcommand)]
+#[command(infer_subcommands = true)]
+#[expect(clippy::large_enum_variant)]
+pub(crate) enum AtuinCmd {
+ #[command(flatten)]
+ Client(client::Cmd),
+
+ /// PTY proxy for atuin
+ #[command(alias = "hex")]
+ PtyProxy(crate::atuin_pty_proxy::PtyProxy),
+
+ /// Generate a UUID
+ Uuid,
+
+ Contributors,
+
+ /// Generate shell completions
+ GenCompletions(gen_completions::Cmd),
+}
+
+impl AtuinCmd {
+ pub(crate) fn run(self) -> Result<()> {
+ #[cfg(not(windows))]
+ {
+ // set umask before we potentially open/create files
+ // or in other words, 077. Do not allow any access to any other user
+ let mode = Mode::RWXG | Mode::RWXO;
+ umask(mode);
+ }
+
+ match self {
+ Self::Client(client) => client.run(),
+
+ Self::PtyProxy(proxy) => {
+ run_pty_proxy(proxy);
+ Ok(())
+ }
+
+ Self::Contributors => {
+ contributors::run();
+ Ok(())
+ }
+ Self::Uuid => {
+ println!("{}", crate::atuin_common::utils::uuid_v7().as_simple());
+ Ok(())
+ }
+ Self::GenCompletions(gen_completions) => gen_completions.run(),
+ }
+ }
+}
+
+#[cfg(unix)]
+fn run_pty_proxy(proxy: crate::atuin_pty_proxy::PtyProxy) {
+ proxy.run(semantic_command_capture_sink());
+}
+
+#[cfg(unix)]
+fn semantic_command_capture_sink() -> Option<crate::atuin_pty_proxy::CommandCaptureSink> {
+ use std::sync::mpsc;
+ use std::time::Duration;
+
+ if is_truthy_env("ATUIN_TERMINAL") {
+ return None;
+ }
+
+ let settings = crate::atuin_client::settings::Settings::new().ok()?;
+ let (tx, rx) = mpsc::sync_channel::<crate::atuin_pty_proxy::CommandCapture>(128);
+
+ std::thread::spawn(move || {
+ let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ else {
+ return;
+ };
+
+ while let Ok(first) = rx.recv() {
+ let mut batch = vec![first];
+
+ while batch.len() < 64 {
+ match rx.recv_timeout(Duration::from_millis(25)) {
+ Ok(capture) => batch.push(capture),
+ Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {
+ break;
+ }
+ }
+ }
+
+ runtime.block_on(send_semantic_command_captures(&settings, batch));
+ }
+ });
+
+ Some(Box::new(move |capture| {
+ drop(tx.try_send(capture));
+ }))
+}
+
+#[cfg(unix)]
+#[inline]
+fn is_truthy_env(name: &str) -> bool {
+ std::env::var(name)
+ .ok()
+ .as_ref()
+ .is_some_and(|value| !value.trim().is_empty() && value.trim() != "false")
+}
+
+#[cfg(unix)]
+async fn send_semantic_command_captures(
+ settings: &crate::atuin_client::settings::Settings,
+ batch: Vec<crate::atuin_pty_proxy::CommandCapture>,
+) {
+ use crate::atuin_daemon::generated;
+
+ let captures = batch
+ .into_iter()
+ .map(|capture| generated::semantic::CommandCapture {
+ prompt: capture.prompt,
+ command: capture.command,
+ output: capture.output,
+ exit_code: capture.exit_code,
+ history_id: capture.history_id,
+ session_id: capture.session_id,
+ output_truncated: capture.output_truncated,
+ output_observed_bytes: capture.output_observed_bytes,
+ })
+ .collect();
+
+ if let Ok(mut client) = crate::atuin_daemon::SemanticClient::from_settings(settings).await {
+ drop(client.record_commands(captures).await);
+ }
+}
diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs
new file mode 100644
index 00000000..82bbc78f
--- /dev/null
+++ b/crates/client/src/lib.rs
@@ -0,0 +1,4 @@
+pub fn test() -> String {
+ dbg!("Hi!");
+ String::new()
+}
diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs
new file mode 100644
index 00000000..12858140
--- /dev/null
+++ b/crates/client/src/main.rs
@@ -0,0 +1,76 @@
+#![forbid(unsafe_code)]
+#![warn(clippy::pedantic, clippy::nursery, clippy::allow_attributes)]
+#![expect(
+ clippy::missing_const_for_fn, // not 100% reliable
+ clippy::redundant_pub_crate,
+)]
+#![expect(
+ clippy::cast_possible_wrap,
+ clippy::cast_sign_loss,
+ clippy::cast_possible_truncation,
+ reason = "We should remove all of these. But it's just a lot of work in this code-base"
+)]
+
+use clap::Parser;
+use clap::builder::Styles;
+use clap::builder::styling::{AnsiColor, Effects};
+use eyre::Result;
+
+use command::AtuinCmd;
+
+mod command;
+
+pub(crate) mod atuin_client;
+pub(crate) mod atuin_common;
+pub(crate) mod atuin_daemon;
+pub(crate) mod atuin_history;
+pub(crate) mod atuin_pty_proxy;
+pub(crate) mod atuin_server;
+
+mod print_error;
+mod sync;
+
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+const SHA: &str = env!("GIT_HASH");
+
+const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")");
+
+static HELP_TEMPLATE: &str = "\
+{before-help}{name} {version}
+{author}
+{about}
+
+{usage-heading}
+ {usage}
+
+{all-args}{after-help}";
+
+const STYLES: Styles = Styles::styled()
+ .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD))
+ .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
+ .literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
+ .placeholder(AnsiColor::Green.on_default());
+
+/// Magical shell history
+#[derive(Parser)]
+#[command(
+ author = "Ellie Huxtable <ellie@atuin.sh>",
+ version = VERSION,
+ long_version = LONG_VERSION,
+ help_template(HELP_TEMPLATE),
+ styles = STYLES,
+)]
+struct Atuin {
+ #[command(subcommand)]
+ atuin: AtuinCmd,
+}
+
+impl Atuin {
+ fn run(self) -> Result<()> {
+ self.atuin.run()
+ }
+}
+
+fn main() -> Result<()> {
+ Atuin::parse().run()
+}
diff --git a/crates/client/src/print_error.rs b/crates/client/src/print_error.rs
new file mode 100644
index 00000000..0a6303dd
--- /dev/null
+++ b/crates/client/src/print_error.rs
@@ -0,0 +1,123 @@
+use std::io::IsTerminal;
+
+use crate::atuin_client::record::sync::SyncError;
+use colored::Colorize;
+use crossterm::terminal;
+
+/// Print a prominent error to stderr. Colored and box-bordered when stderr is
+/// a TTY, plain "Error: ..." header otherwise. The description is word-wrapped
+/// to the terminal width (capped at 100 columns) so the message stays readable.
+pub(crate) fn print_error(title: &str, description: &str) {
+ let is_tty = std::io::stderr().is_terminal();
+ let width = if is_tty {
+ terminal::size().map_or(80, |(w, _)| w as usize)
+ } else {
+ 80
+ }
+ .min(100);
+
+ eprintln!();
+ if is_tty {
+ let bar = "━".repeat(width).red().bold().to_string();
+ eprintln!("{bar}");
+ eprintln!(" {} {}", "✗".red().bold(), title.red().bold());
+ eprintln!("{bar}");
+ } else {
+ eprintln!("Error: {title}");
+ eprintln!("{}", "-".repeat(width));
+ }
+ eprintln!();
+
+ for line in wrap_text(description, width.saturating_sub(2)) {
+ eprintln!(" {line}");
+ }
+ eprintln!();
+}
+
+/// Convert a `SyncError` into an `eyre::Report`, exiting on `WrongKey` after
+/// painting the prominent banner.
+pub(crate) fn format_sync_error(e: SyncError) -> eyre::Report {
+ if matches!(e, SyncError::WrongKey) {
+ print_error(
+ "Wrong encryption key",
+ "Your local encryption key cannot decrypt the data on the server. \
+ This usually means another machine wrote records with a different key.\n\n\
+ To fix this, find the correct key by running `atuin key` on a machine that \
+ already syncs successfully, then run `atuin store rekey <key>` here.",
+ );
+ std::process::exit(1);
+ }
+ e.into()
+}
+
+fn wrap_text(text: &str, width: usize) -> Vec<String> {
+ let mut out = Vec::new();
+ for paragraph in text.split('\n') {
+ let mut line = String::new();
+ let mut line_len = 0;
+ for word in paragraph.split_whitespace() {
+ let word_len = word.chars().count();
+ if !line.is_empty() && line_len + 1 + word_len > width {
+ out.push(std::mem::take(&mut line));
+ line_len = 0;
+ }
+ if !line.is_empty() {
+ line.push(' ');
+ line_len += 1;
+ }
+ line.push_str(word);
+ line_len += word_len;
+ }
+ // Push every paragraph's final line (even empty) so `\n\n` in the
+ // input becomes a blank line in the output.
+ out.push(line);
+ }
+ while out.first().is_some_and(String::is_empty) {
+ out.remove(0);
+ }
+ while out.last().is_some_and(String::is_empty) {
+ out.pop();
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::wrap_text;
+
+ #[test]
+ fn wraps_long_text() {
+ let lines = wrap_text("the quick brown fox jumps over the lazy dog", 20);
+ for line in &lines {
+ assert!(line.chars().count() <= 20, "line too long: {line:?}");
+ }
+ assert_eq!(
+ lines.join(" "),
+ "the quick brown fox jumps over the lazy dog"
+ );
+ }
+
+ #[test]
+ fn preserves_explicit_newlines() {
+ let lines = wrap_text("first line\nsecond line", 80);
+ assert_eq!(lines, vec!["first line", "second line"]);
+ }
+
+ #[test]
+ fn handles_word_longer_than_width() {
+ let lines = wrap_text("short superlongword more", 5);
+ assert_eq!(lines, vec!["short", "superlongword", "more"]);
+ }
+
+ #[test]
+ fn preserves_blank_lines_between_paragraphs() {
+ let lines = wrap_text("first paragraph\n\nsecond paragraph", 80);
+ assert_eq!(lines, vec!["first paragraph", "", "second paragraph"]);
+ }
+
+ #[test]
+ fn trims_leading_and_trailing_blank_lines() {
+ let lines = wrap_text("\n\nbody\n\n", 80);
+ assert_eq!(lines, vec!["body"]);
+ }
+}
diff --git a/crates/client/src/shell/.gitattributes b/crates/client/src/shell/.gitattributes
new file mode 100644
index 00000000..fae8897c
--- /dev/null
+++ b/crates/client/src/shell/.gitattributes
@@ -0,0 +1 @@
+* eol=lf
diff --git a/crates/client/src/shell/atuin.bash b/crates/client/src/shell/atuin.bash
new file mode 100644
index 00000000..703e8fe2
--- /dev/null
+++ b/crates/client/src/shell/atuin.bash
@@ -0,0 +1,672 @@
+# Include guard
+if [[ ${__atuin_initialized-} == true ]]; then
+ false
+elif [[ $- != *i* ]]; then
+ # Enable only in interactive shells
+ false
+elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then
+ # Require bash >= 3.1
+ [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\n' >&2
+ false
+else # (include guard) beginning of main content
+ #------------------------------------------------------------------------------
+ __atuin_initialized=true
+
+ if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
+ ATUIN_SESSION=$(atuin uuid)
+ export ATUIN_SESSION
+ export ATUIN_SHLVL=$SHLVL
+ fi
+ ATUIN_STTY=$(stty -g)
+ ATUIN_HISTORY_ID=""
+
+ __atuin_osc133_command_executed() {
+ [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
+ [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
+
+ printf '\033]133;C\a'
+ }
+
+ __atuin_osc133_command_finished() {
+ [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
+ [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
+
+ printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
+ }
+
+ __atuin_osc133_prompt_start=$'\001\033]133;A;cl=line\a\002'
+ __atuin_osc133_prompt_end=$'\001\033]133;B\a\002'
+
+ __atuin_osc133_wrap_prompt() {
+ local __atuin_prompt="${PS1-}"
+ __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
+ __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
+
+ if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
+ PS1="${__atuin_osc133_prompt_start}${__atuin_prompt}${__atuin_osc133_prompt_end}"
+ else
+ PS1="$__atuin_prompt"
+ fi
+ }
+
+ export ATUIN_PREEXEC_BACKEND=$SHLVL:none
+ __atuin_update_preexec_backend() {
+ if [[ ${BLE_ATTACHED-} ]]; then
+ ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-}
+ elif [[ ${bash_preexec_imported-} ]]; then
+ ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec
+ elif [[ ${__bp_imported-} ]]; then
+ ATUIN_PREEXEC_BACKEND="$SHLVL:bash-preexec (old)"
+ else
+ ATUIN_PREEXEC_BACKEND=$SHLVL:unknown
+ fi
+ }
+
+ __atuin_preexec() {
+ # Workaround for old versions of bash-preexec
+ if [[ ! ${BLE_ATTACHED-} ]]; then
+ # In older versions of bash-preexec, the preexec hook may be called
+ # even for the commands run by keybindings. There is no general and
+ # robust way to detect the command for keybindings, but at least we
+ # want to exclude Atuin's keybindings. When the preexec hook is called
+ # for a keybinding, the preexec hook for the user command will not
+ # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify
+ # __atuin_precmd of this failure.
+ if [[ $BASH_COMMAND != "$1" ]]; then
+ case $BASH_COMMAND in
+ '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*)
+ ATUIN_HISTORY_ID=__bash_preexec_failure__
+ return 0
+ ;;
+ esac
+ fi
+ fi
+
+ # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's
+ # attaching state can dynamically change.
+ __atuin_update_preexec_backend
+
+ local id
+ id=$(atuin history start -- "$1" 2>/dev/null)
+ export ATUIN_HISTORY_ID=$id
+ [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_executed
+ __atuin_preexec_time=${EPOCHREALTIME-}
+ }
+
+ __atuin_precmd() {
+ local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-}
+
+ __atuin_osc133_wrap_prompt
+
+ [[ ! $ATUIN_HISTORY_ID ]] && return
+
+ # If the previous preexec hook failed, we manually call __atuin_preexec
+ local __atuin_skip_osc133=""
+ if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then
+ # This is the command extraction code taken from bash-preexec
+ local previous_command
+ previous_command=$(
+ export LC_ALL=C HISTTIMEFORMAT=''
+ builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
+ )
+ __atuin_skip_osc133=1
+ __atuin_preexec "$previous_command"
+ fi
+
+ local duration=""
+ # shellcheck disable=SC2154,SC2309
+ if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then
+ # With ble.sh, we utilize the shell variable `_ble_exec_time_ata`
+ # recorded by ble.sh. It is more accurate than the measurements by
+ # Atuin, which includes the spawn cost of Atuin. ble.sh uses the
+ # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the
+ # microsecond resolution, or the builtin `time` in bash < 5.0 with the
+ # millisecond resolution.
+ duration=${_ble_exec_time_ata}000
+ elif ((BASH_VERSINFO[0] >= 5)); then
+ # We calculate the high-resolution duration based on EPOCHREALTIME
+ # (bash >= 5.0) recorded by precmd/preexec, though it might not be as
+ # accurate as `_ble_exec_time_ata` provided by ble.sh because it
+ # includes the extra time of the precmd/preexec handling. Since Bash
+ # does not offer floating-point arithmetic, we remove the non-digit
+ # characters and perform the integral arithmetic. The fraction part of
+ # EPOCHREALTIME is fixed to have 6 digits in Bash. We remove all the
+ # non-digit characters because the decimal point is not necessarily a
+ # period depending on the locale.
+ duration=$((${__atuin_precmd_time//[!0-9]/} - ${__atuin_preexec_time//[!0-9]/}))
+ if ((duration >= 0)); then
+ duration=${duration}000
+ else
+ duration="" # clear the result on overflow
+ fi
+ fi
+
+ [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_finished "$EXIT"
+ (ATUIN_LOG=error atuin history end --exit "$EXIT" ${duration:+"--duration=$duration"} -- "$ATUIN_HISTORY_ID" &) >/dev/null 2>&1
+ export ATUIN_HISTORY_ID=""
+ }
+
+ __atuin_set_ret_value() {
+ return ${1:+"$1"}
+ }
+
+ #------------------------------------------------------------------------------
+ # section: __atuin_accept_line
+ #
+ # The function "__atuin_accept_line" is kept for backward compatibility of the
+ # direct use of __atuin_history in keybindings by users.
+
+ # The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in
+ # $PS1. We switch the implementation of the shell function
+ # `__atuin_evaluate_prompt` based on the Bash version because the expansion
+ # ${PS1@P} is only available in bash >= 4.4.
+ if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then
+ __atuin_evaluate_prompt() {
+ __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
+ __atuin_prompt=${PS1@P}
+
+ # Note: Strip the control characters ^A (\001) and ^B (\002), which
+ # Bash internally uses to enclose the escape sequences. They are
+ # produced by '\[' and '\]', respectively, in $PS1 and used to tell
+ # Bash that the strings inbetween do not contribute to the prompt
+ # width. After the prompt width calculation, Bash strips those control
+ # characters before outputting it to the terminal. We here strip these
+ # characters following Bash's behavior.
+ __atuin_prompt=${__atuin_prompt//[$'\001\002']/}
+
+ # Count the number of newlines contained in $__atuin_prompt
+ __atuin_prompt_offset=${__atuin_prompt//[!$'\n']/}
+ __atuin_prompt_offset=${#__atuin_prompt_offset}
+ }
+ else
+ __atuin_evaluate_prompt() {
+ __atuin_prompt='$ '
+ __atuin_prompt_offset=0
+ }
+ fi
+
+ # The shell function `__atuin_clear_prompt N` outputs terminal control
+ # sequences to clear the contents of the current and N previous lines. After
+ # clearing, the cursor is placed at the beginning of the N-th previous line.
+ __atuin_clear_prompt_cache=()
+ __atuin_clear_prompt() {
+ local offset=$1
+ if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then
+ if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then
+ __atuin_clear_prompt_cache[0]=$'\r'$(tput el 2>/dev/null || tput ce 2>/dev/null)
+ fi
+ if ((offset > 0)); then
+ __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$(
+ tput cuu "$offset" 2>/dev/null || tput UP "$offset" 2>/dev/null
+ tput dl "$offset" 2>/dev/null || tput DL "$offset" 2>/dev/null
+ tput il "$offset" 2>/dev/null || tput AL "$offset" 2>/dev/null
+ )
+ fi
+ fi
+ printf '%s' "${__atuin_clear_prompt_cache[offset]}"
+ }
+
+ __atuin_accept_line() {
+ local __atuin_command=$1
+
+ # Reprint the prompt, accounting for multiple lines
+ local __atuin_prompt __atuin_prompt_offset
+ __atuin_evaluate_prompt
+ __atuin_clear_prompt "$__atuin_prompt_offset"
+ printf '%s\n' "$__atuin_prompt$__atuin_command"
+
+ # Add it to the bash history
+ history -s "$__atuin_command"
+
+ # Assuming bash-preexec
+ # Invoke every function in the preexec array
+ local __atuin_preexec_function
+ local __atuin_preexec_function_ret_value
+ local __atuin_preexec_ret_value=0
+ for __atuin_preexec_function in "${preexec_functions[@]:-}"; do
+ if type -t "$__atuin_preexec_function" 1>/dev/null; then
+ __atuin_set_ret_value "${__bp_last_ret_value:-}"
+ "$__atuin_preexec_function" "$__atuin_command"
+ __atuin_preexec_function_ret_value=$?
+ if [[ $__atuin_preexec_function_ret_value != 0 ]]; then
+ __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value
+ fi
+ fi
+ done
+
+ # If extdebug is turned on and any preexec function returns non-zero
+ # exit status, we do not run the user command.
+ if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then
+ # Note: When a child Bash session is started by enter_accept, if the
+ # environment variable READLINE_POINT is present, bash-preexec in the
+ # child session does not fire preexec at all because it considers we
+ # are inside Atuin's keybinding of the current session. To avoid
+ # propagating the environment variable to the child session, we remove
+ # the export attribute of READLINE_LINE and READLINE_POINT.
+ export -n READLINE_LINE READLINE_POINT
+
+ # Juggle the terminal settings so that the command can be interacted
+ # with
+ local __atuin_stty_backup
+ __atuin_stty_backup=$(stty -g)
+ stty "$ATUIN_STTY"
+
+ # Execute the command. Note: We need to record $? and $_ after the
+ # user command within the same call of "eval" because $_ is otherwise
+ # overwritten by the last argument of "eval".
+ __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
+ eval -- "$__atuin_command"$'\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_'
+
+ stty "$__atuin_stty_backup"
+ fi
+
+ # Execute preprompt commands
+ local __atuin_prompt_command
+ for __atuin_prompt_command in "${PROMPT_COMMAND[@]}"; do
+ __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
+ eval -- "$__atuin_prompt_command"
+ done
+ # Bash will redraw only the line with the prompt after we finish,
+ # so to work for a multiline prompt we need to print it ourselves,
+ # then go to the beginning of the last line.
+ __atuin_evaluate_prompt
+ printf '%s' "$__atuin_prompt"
+ __atuin_clear_prompt 0
+ }
+
+ #------------------------------------------------------------------------------
+
+ __atuin_search_cmd() {
+ local -a search_args=("$@")
+
+ ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
+ }
+
+ __atuin_history() {
+ # Default action of the up key: When this function is called with the first
+ # argument `--shell-up-key-binding`, we perform Atuin's history search only
+ # when the up key is supposed to cause the history movement in the original
+ # binding. We do this only for ble.sh because the up key always invokes
+ # the history movement in the plain Bash.
+ if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then
+ # When the current cursor position is not in the first line, the up key
+ # should move the cursor to the previous line. While the selection is
+ # performed, the up key should not start the history search.
+ # shellcheck disable=SC2154 # Note: these variables are set by ble.sh
+ if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\n'* || $_ble_edit_mark_active ]]; then
+ ble/widget/@nomarked backward-line
+ local status=$?
+ READLINE_LINE=$_ble_edit_str
+ READLINE_POINT=$_ble_edit_ind
+ READLINE_MARK=$_ble_edit_mark
+ return "$status"
+ fi
+ fi
+
+ # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or
+ # ble.sh. When it is not supported, we clear them to suppress strange
+ # behaviors.
+ [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) ||
+ READLINE_LINE="" READLINE_POINT=0
+
+ local __atuin_output
+ if ! __atuin_output=$(__atuin_search_cmd "$@"); then
+ [[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2
+ return 1
+ fi
+
+ # We do nothing when the search is canceled.
+ [[ $__atuin_output ]] || return 0
+
+ if [[ $__atuin_output == __atuin_accept__:* ]]; then
+ __atuin_output=${__atuin_output#__atuin_accept__:}
+
+ if [[ ${BLE_ATTACHED-} ]]; then
+ ble-edit/content/reset-and-check-dirty "$__atuin_output"
+ ble/widget/accept-line
+ READLINE_LINE=""
+ elif [[ ${__atuin_macro_chain_keymap-} ]]; then
+ READLINE_LINE=$__atuin_output
+ bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_accept_line"
+ else
+ __atuin_accept_line "$__atuin_output"
+ READLINE_LINE=""
+ fi
+
+ READLINE_POINT=${#READLINE_LINE}
+ else
+ READLINE_LINE=$__atuin_output
+ READLINE_POINT=${#READLINE_LINE}
+ if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then
+ bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_insert_line"
+ fi
+ fi
+ }
+
+ __atuin_initialize_blesh() {
+ # shellcheck disable=SC2154
+ [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0
+
+ ble-import contrib/integration/bash-preexec
+
+ # Define and register an autosuggestion source for ble.sh's auto-complete.
+ # If you'd like to overwrite this, define the same name of shell function
+ # after the $(atuin init bash) line in your .bashrc. If you do not need
+ # the auto-complete source by Atuin, please add the following code to
+ # remove the entry after the $(atuin init bash) line in your .bashrc:
+ #
+ # ble/util/import/eval-after-load core-complete '
+ # ble/array#remove _ble_complete_auto_source atuin-history'
+ #
+ function ble/complete/auto-complete/source:atuin-history {
+ local suggestion
+ suggestion=$(ATUIN_QUERY="$_ble_edit_str" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
+ [[ $suggestion == "$_ble_edit_str"?* ]] || return 1
+ ble/complete/auto-complete/enter h 0 "${suggestion:${#_ble_edit_str}}" '' "$suggestion"
+ }
+ ble/util/import/eval-after-load core-complete '
+ ble/array#unshift _ble_complete_auto_source atuin-history'
+
+ # @env BLE_SESSION_ID: `atuin doctor` references the environment variable
+ # BLE_SESSION_ID. We explicitly export the variable because it was not
+ # exported in older versions of ble.sh.
+ [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID
+ }
+ __atuin_initialize_blesh
+ BLE_ONLOAD+=(__atuin_initialize_blesh)
+ precmd_functions+=(__atuin_precmd)
+ preexec_functions+=(__atuin_preexec)
+
+ #------------------------------------------------------------------------------
+ # section: atuin-bind
+
+ __atuin_widget=()
+
+ __atuin_widget_save() {
+ local data=$1
+ for REPLY in "${!__atuin_widget[@]}"; do
+ if [[ ${__atuin_widget[REPLY]} == "$data" ]]; then
+ return 0
+ fi
+ done
+ # shellcheck disable=SC2154
+ REPLY=${#__atuin_widget[*]}
+ __atuin_widget[REPLY]=$data
+ }
+
+ __atuin_widget_run() {
+ local data=${__atuin_widget[$1]}
+ local keymap=${data%%:*} widget=${data#*:}
+ local __atuin_macro_chain_keymap=$keymap
+ bind -m "$keymap" '"'"$__atuin_macro_chain"'": ""'
+ builtin eval -- "$widget"
+ }
+
+ # To realize the enter_accept feature in a robust way, we need to call the
+ # readline bindable function `accept-line'. However, there is no way to call
+ # `accept-line' from the shell script. To call the bindable function
+ # `accept-line', we may utilize string macros of readline. When we bind KEYSEQ
+ # to a WIDGET that wants to conditionally call `accept-line' at the end, we
+ # perform two-step dispatching:
+ #
+ # 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two
+ # intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros. For
+ # example, when we bind `__atuin_history` to \C-r, this step can be set up by
+ # `bind '"\C-r": "IKEYSEQ1IKEYSEQ2"'`.
+ #
+ # 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the
+ # binding of IKEYSEQ2 is dynamically determined by WIDGET. For example, when
+ # we bind `__atuin_history` to \C-r, this step can be set up by `bind -x
+ # '"IKEYSEQ1": WIDGET'`.
+ #
+ # 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> ""]---To request the execution
+ # of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running
+ # `bind '"IKEYSEQ2": accept-line''. Otherwise, WIDGET can change the binding
+ # of IKEYSEQ2 to no-op by running `bind '"IKEYSEQ2": ""'`.
+ #
+ # For the choice of the intermediate key sequences, we want to choose key
+ # sequences that are unlikely to conflict with others. In addition, we want to
+ # avoid a key sequence containing \e because keymap "vi-insert" stops
+ # processing key sequences containing \e in older versions of Bash. We have
+ # used \e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0
+ # for intermediate key sequences, but this contains \e and caused a problem.
+ # Instead, we use \C-x\C-_A<n>\a, which starts with \C-x\C-_ (an unlikely
+ # two-byte combination) and A (represents the initial letter of Atuin),
+ # followed by the payload <n> and the terminator \a (BEL, \C-g).
+
+ __atuin_macro_chain='\C-x\C-_A0\a'
+ for __atuin_keymap in emacs vi-insert vi-command; do
+ bind -m "$__atuin_keymap" "\"$__atuin_macro_chain\": \"\""
+ done
+ unset -v __atuin_keymap
+
+ if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then
+ # In Bash >= 4.3
+
+ __atuin_macro_accept_line=accept-line
+
+ __atuin_bind_impl() {
+ local keymap=$1 keyseq=$2 command=$3
+
+ # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the
+ # command is shared by all the keymaps (emacs, vi-insert, and
+ # vi-command), so one cannot safely bind different command strings to
+ # the same keyseq in different keymaps. Therefore, the command string
+ # and the keyseq need to be globally in one-to-one correspondence in
+ # all the keymaps.
+ local REPLY
+ __atuin_widget_save "$keymap:$command"
+ local widget=$REPLY
+ local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a'
+ local ikeyseq2=$__atuin_macro_chain
+
+ if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then
+ # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an
+ # existing "bind -x" keybinding breaks other existing "bind -x"
+ # keybindings [1,2]. To work around the problem, we explicitly
+ # unbind an existing keybinding before overwriting it.
+ #
+ # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html
+ # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291
+ bind -m "$keymap" -r "$keyseq"
+ fi
+
+ bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\""
+ bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget"
+ }
+
+ __atuin_bind_blesh_onload() {
+ # In ble.sh, we need to enable unrecognized CSI sequences like \e[0;0A,
+ # which are discarded by ble.sh by default. Note: In Bash <= 4.2, we
+ # do not need to unset "decode_error_cseq_discard" because \e[0;<m>A is
+ # used only for the macro chaining (which is unused by ble.sh) in Bash
+ # <= 4.2.
+ bleopt decode_error_cseq_discard=
+ }
+ if [[ ${BLE_VERSION-} ]]; then
+ __atuin_bind_blesh_onload
+ fi
+ BLE_ONLOAD+=(__atuin_bind_blesh_onload)
+ else
+ # In Bash <= 4.2, "bind -x" cannot bind a shell command to a keyseq having
+ # more than two bytes, so we need to work with only two-byte sequences.
+ #
+ # However, the number of available combinations of two-byte sequences is
+ # limited. To minimize the number of key sequences used by Atuin, instead
+ # of specifying a widget by its own intermediate sequence, we specify a
+ # widget by a fixed-length sequence of multiple two-byte sequences. More
+ # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5]
+ # IKSX, where IKS1..IKS5 just stores its information to a global variable,
+ # and IKSX collects all the information and determine and call the actual
+ # widget based on the stored information. Each of IKn (n=1..5) is one of
+ # the two reserved sequences, $__atuin_bash42_code0 and
+ # $__atuin_bash42_code1. IKSX is fixed to be $__atuin_bash42_code2.
+ #
+ # For the choices of the special key sequences, we consider \C-xQ, \C-xR,
+ # and \C-xS. In the emacs editing mode of Bash, \C-x is used as a prefix
+ # key, i.e., it is used for the beginning key of the keybindings with
+ # multiple keys, so \C-x is unlikely to be used for a single-key binding by
+ # the user. Also, \C-x is not used in the vi editing mode by default. The
+ # combinations \C-xQ..\C-xS are also unlikely be used because we need to
+ # switch the modifier keys from Control to Shift to input these sequences,
+ # and these are not easy to input.
+ __atuin_bash42_code0='\C-xQ'
+ __atuin_bash42_code1='\C-xR'
+ __atuin_bash42_code2='\C-xS'
+
+ __atuin_bash42_encode() {
+ REPLY=
+ local n=$1 min_width=${2-}
+ while
+ if ((n % 2 == 0)); then
+ REPLY=$__atuin_bash42_code0$REPLY
+ else
+ REPLY=$__atuin_bash42_code1$REPLY
+ fi
+ (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width))
+ do :; done
+ }
+
+ __atuin_bash42_bind() {
+ local __atuin_keymap
+ for __atuin_keymap in emacs vi-insert vi-command; do
+ bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code0"'": __atuin_bash42_dispatch_selector+=0'
+ bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code1"'": __atuin_bash42_dispatch_selector+=1'
+ bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code2"'": __atuin_bash42_dispatch'
+ done
+ }
+ __atuin_bash42_bind
+ # In Bash <= 4.2, there is no way to read users' "bind -x" settings, so we
+ # need to explicitly perform "bind -x" when ble.sh is loaded.
+ BLE_ONLOAD+=(__atuin_bash42_bind)
+
+ if ((BASH_VERSINFO[0] >= 4)); then
+ __atuin_macro_accept_line=accept-line
+ else
+ # Note: We rewrite the command line and invoke `accept-line'. In
+ # bash <= 3.2, there is no way to rewrite the command line from the
+ # shell script, so we rewrite it using a macro and
+ # `shell-expand-line'.
+ #
+ # Note: Concerning the key sequences to invoke bindable functions
+ # such as "\C-x\C-_A1\a", another option is to use
+ # "\exbegginning-of-line\r", etc. to make it consistent with bash
+ # >= 5.3. However, an older Bash configuration can still conflict
+ # on [M-x]. The conflict is more likely than \C-x\C-_A1\a.
+ for __atuin_keymap in emacs vi-insert vi-command; do
+ bind -m "$__atuin_keymap" '"\C-x\C-_A1\a": beginning-of-line'
+ bind -m "$__atuin_keymap" '"\C-x\C-_A2\a": kill-line'
+ # shellcheck disable=SC2016
+ bind -m "$__atuin_keymap" '"\C-x\C-_A3\a": "$READLINE_LINE"'
+ bind -m "$__atuin_keymap" '"\C-x\C-_A4\a": shell-expand-line'
+ bind -m "$__atuin_keymap" '"\C-x\C-_A5\a": accept-line'
+ bind -m "$__atuin_keymap" '"\C-x\C-_A6\a": end-of-line'
+ done
+ unset -v __atuin_keymap
+
+ bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode'
+ bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode'
+
+ # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are
+ # in the vi-command keymap, we go to vi-insert, input
+ # "$READLINE_LINE", and come back to vi-command.
+ bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
+ bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
+ bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"'
+
+ __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"'
+ __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"'
+ fi
+
+ __atuin_bash42_dispatch_selector=
+
+ __atuin_bash42_dispatch() {
+ local s=$__atuin_bash42_dispatch_selector
+ __atuin_bash42_dispatch_selector=
+ __atuin_widget_run "$((2#0$s))"
+ }
+
+ __atuin_bind_impl() {
+ local keymap=$1 keyseq=$2 command=$3
+
+ __atuin_widget_save "$keymap:$command"
+ __atuin_bash42_encode "$REPLY"
+ local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain
+
+ bind -m "$keymap" "\"$keyseq\": \"$macro\""
+ }
+ fi
+
+ atuin-bind() {
+ local keymap=
+ local OPTIND=1 OPTARG="" OPTERR=0 flag
+ while getopts ':m:' flag "$@"; do
+ case $flag in
+ m) keymap=$OPTARG ;;
+ *)
+ printf '%s\n' "atuin-bind: unrecognized option '-$flag'" >&2
+ return 2
+ ;;
+ esac
+ done
+ shift "$((OPTIND - 1))"
+
+ if (($# != 2)); then
+ printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2
+ return 2
+ fi
+
+ local keyseq=$1
+ [[ $keymap ]] || keymap=$(bind -v | awk '$2 == "keymap" { print $3 }')
+ case $keymap in
+ emacs-meta) keymap=emacs keyseq='\e'$keyseq ;;
+ emacs-ctlx) keymap=emacs keyseq='\C-x'$keyseq ;;
+ emacs*) keymap=emacs ;;
+ vi-insert) ;;
+ vi*) keymap=vi-command ;;
+ *)
+ printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2
+ return 2
+ ;;
+ esac
+
+ local command=$2 widget=${2%%[[:blank:]]*}
+ case $widget in
+ atuin-search) command=${2/#"$widget"/__atuin_history} ;;
+ atuin-search-emacs) command=${2/#"$widget"/__atuin_history --keymap-mode=emacs} ;;
+ atuin-search-viins) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-insert} ;;
+ atuin-search-vicmd) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-normal} ;;
+ atuin-up-search) command=${2/#"$widget"/__atuin_history --shell-up-key-binding} ;;
+ atuin-up-search-emacs) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;;
+ atuin-up-search-viins) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;;
+ atuin-up-search-vicmd) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;;
+ esac
+
+ __atuin_bind_impl "$keymap" "$keyseq" "$command"
+ }
+
+ #------------------------------------------------------------------------------
+
+ # shellcheck disable=SC2154
+ if [[ $__atuin_bind_ctrl_r == true ]]; then
+ # Note: We do not overwrite [C-r] in the vi-command keymap because we do
+ # not want to overwrite "redo", which is already bound to [C-r] in the
+ # vi_nmap keymap in ble.sh.
+ atuin-bind -m emacs '\C-r' atuin-search-emacs
+ atuin-bind -m vi-insert '\C-r' atuin-search-viins
+ atuin-bind -m vi-command '/' atuin-search-emacs
+ fi
+
+ # shellcheck disable=SC2154
+ if [[ $__atuin_bind_up_arrow == true ]]; then
+ atuin-bind -m emacs '\e[A' atuin-up-search-emacs
+ atuin-bind -m emacs '\eOA' atuin-up-search-emacs
+ atuin-bind -m vi-insert '\e[A' atuin-up-search-viins
+ atuin-bind -m vi-insert '\eOA' atuin-up-search-viins
+ atuin-bind -m vi-command '\e[A' atuin-up-search-vicmd
+ atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd
+ atuin-bind -m vi-command 'k' atuin-up-search-vicmd
+ fi
+
+#------------------------------------------------------------------------------
+fi # (include guard) end of main content
diff --git a/crates/client/src/shell/atuin.fish b/crates/client/src/shell/atuin.fish
new file mode 100644
index 00000000..2b469383
--- /dev/null
+++ b/crates/client/src/shell/atuin.fish
@@ -0,0 +1,102 @@
+if not set -q ATUIN_SESSION; or test "$ATUIN_SHLVL" != "$SHLVL"
+ set -gx ATUIN_SESSION (atuin uuid)
+ set -gx ATUIN_SHLVL $SHLVL
+end
+set --erase ATUIN_HISTORY_ID
+
+function _atuin_osc133_command_executed
+ set -q ATUIN_PTY_PROXY_ACTIVE; or return
+ test -n "$ATUIN_HISTORY_ID"; or return
+
+ printf '\033]133;C\a'
+end
+
+function _atuin_osc133_command_finished --argument-names exit_code
+ set -q ATUIN_PTY_PROXY_ACTIVE; or return
+ test -n "$ATUIN_HISTORY_ID"; or return
+
+ printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$exit_code" "$ATUIN_HISTORY_ID" "$ATUIN_SESSION"
+end
+
+function _atuin_preexec --on-event fish_preexec
+ if not test -n "$fish_private_mode"
+ set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]" 2>/dev/null)
+ _atuin_osc133_command_executed
+ end
+end
+
+function _atuin_postexec --on-event fish_postexec
+ set -l s $status
+
+ if test -n "$ATUIN_HISTORY_ID"
+ _atuin_osc133_command_finished $s
+ ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null &
+ disown
+ end
+
+ set --erase ATUIN_HISTORY_ID
+end
+
+function _atuin_search
+ set -l keymap_mode
+ switch $fish_key_bindings
+ case fish_vi_key_bindings fish_hybrid_key_bindings
+ switch $fish_bind_mode
+ case default
+ set keymap_mode vim-normal
+ case insert
+ set keymap_mode vim-insert
+ end
+ case '*'
+ set keymap_mode emacs
+ end
+
+ set -l ATUIN_H
+ set -l ATUIN_STATUS 0
+
+ # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate;
+ # but to support fish 3.3 we need to use `(some command | string collect)`.
+ # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes")
+ set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 3>&- | string collect)
+ set ATUIN_STATUS $pipestatus[1]
+
+ if test "$ATUIN_STATUS" -ne 0
+ test -n "$ATUIN_H"; and printf '%s\n' "$ATUIN_H" >&2
+ commandline -f repaint
+ return "$ATUIN_STATUS"
+ end
+
+ set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace
+
+ if test -n "$ATUIN_H"
+ if string match --quiet '__atuin_accept__:*' "$ATUIN_H"
+ set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect)
+ commandline -r "$ATUIN_HIST"
+ commandline -f repaint
+ commandline -f execute
+ return
+ else
+ commandline -r "$ATUIN_H"
+ end
+ end
+
+ commandline -f repaint
+end
+
+function _atuin_bind_up
+ # Fallback to fish's builtin up-or-search if we're in search or paging mode
+ if commandline --search-mode; or commandline --paging-mode
+ up-or-search
+ return
+ end
+
+ # Only invoke atuin if we're on the top line of the command
+ set -l lineno (commandline --line)
+
+ switch $lineno
+ case 1
+ _atuin_search --shell-up-key-binding
+ case '*'
+ up-or-search
+ end
+end
diff --git a/crates/client/src/shell/atuin.nu b/crates/client/src/shell/atuin.nu
new file mode 100644
index 00000000..d37457e4
--- /dev/null
+++ b/crates/client/src/shell/atuin.nu
@@ -0,0 +1,121 @@
+# Source this in your ~/.config/nushell/config.nu
+# minimum supported version = 0.93.0
+module compat {
+ export def --wrapped "random uuid -v 7" [...rest] { atuin uuid }
+}
+use (if not (
+ (version).major > 0 or
+ (version).minor >= 103
+) { "compat" }) *
+
+if 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default "")) {
+ $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a "-" "")
+ $env.ATUIN_SHLVL = ($env.SHLVL? | default "")
+}
+hide-env -i ATUIN_HISTORY_ID
+
+def _atuin_osc133_command_executed [] {
+ if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
+ return
+ }
+ if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
+ return
+ }
+
+ print -n $"(char esc)]133;C(char bel)"
+}
+
+def _atuin_osc133_command_finished [exit_code: int] {
+ if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
+ return
+ }
+ if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
+ return
+ }
+
+ print -n $"(char esc)]133;D;($exit_code);history_id=($env.ATUIN_HISTORY_ID);session_id=($env.ATUIN_SESSION)(char bel)"
+}
+
+# Magic token to make sure we don't record commands run by keybindings
+let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)"
+
+let _atuin_pre_execution = {||
+ if ($nu | get history-enabled?) == false {
+ return
+ }
+ let cmd = (commandline)
+ if ($cmd | is-empty) {
+ return
+ }
+ if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) {
+ $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd | complete | get stdout | str trim)
+ _atuin_osc133_command_executed
+ }
+}
+
+let _atuin_pre_prompt = {||
+ let last_exit = $env.LAST_EXIT_CODE
+ if 'ATUIN_HISTORY_ID' not-in $env {
+ return
+ }
+ _atuin_osc133_command_finished $last_exit
+ with-env { ATUIN_LOG: error } {
+ if (version).minor >= 104 or (version).major > 0 {
+ job spawn {
+ ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete
+ } | ignore
+ } else {
+ do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete
+ }
+
+ }
+ hide-env ATUIN_HISTORY_ID
+}
+
+def _atuin_search_cmd [...flags: string] {
+ if (version).minor >= 106 or (version).major > 0 {
+ [
+ $ATUIN_KEYBINDING_TOKEN,
+ ([
+ `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`,
+ ([
+ 'let output = (run-external atuin search',
+ ($flags | append [--interactive] | each {|e| $'"($e)"'}),
+ 'e>| str trim)',
+ ] | flatten | str join ' '),
+ 'if ($output | str starts-with "__atuin_accept__:") {',
+ 'commandline edit --accept ($output | str replace "__atuin_accept__:" "")',
+ '} else {',
+ 'commandline edit $output',
+ '}',
+ `}`,
+ ] | flatten | str join "\n"),
+ ]
+ } else {
+ [
+ $ATUIN_KEYBINDING_TOKEN,
+ ([
+ `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`,
+ 'commandline edit',
+ '(run-external atuin search',
+ ($flags | append [--interactive] | each {|e| $'"($e)"'}),
+ ' e>| str trim)',
+ `}`,
+ ] | flatten | str join ' '),
+ ]
+ } | str join "\n"
+}
+
+$env.config = ($env | default {} config).config
+$env.config = ($env.config | default {} hooks)
+$env.config = (
+ $env.config | upsert hooks (
+ $env.config.hooks
+ | upsert pre_execution (
+ $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution)
+ | upsert pre_prompt (
+ $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt)
+ )
+)
+
+$env.config = ($env.config | default [] keybindings)
diff --git a/crates/client/src/shell/atuin.ps1 b/crates/client/src/shell/atuin.ps1
new file mode 100644
index 00000000..431ee2c3
--- /dev/null
+++ b/crates/client/src/shell/atuin.ps1
@@ -0,0 +1,240 @@
+# Atuin PowerShell module
+#
+# This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux.
+#
+# Usage: atuin init powershell | Out-String | Invoke-Expression
+#
+# Settings:
+# - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search.
+# This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt.
+# It is initialized from the current prompt line count if not set when the first Atuin search is performed.
+
+if (Get-Module Atuin -ErrorAction Ignore) {
+ if ($PSVersionTable.PSVersion.Major -ge 7) {
+ Write-Warning "The Atuin module is already loaded, replacing it."
+ Remove-Module Atuin
+ } else {
+ Write-Warning "The Atuin module is already loaded, skipping."
+ return
+ }
+}
+
+if (!(Get-Command atuin -ErrorAction Ignore)) {
+ Write-Error "The 'atuin' executable needs to be available in the PATH."
+ return
+}
+
+if (!(Get-Module PSReadLine -ErrorAction Ignore)) {
+ Write-Error "Atuin requires the PSReadLine module to be installed."
+ return
+}
+
+New-Module -Name Atuin -ScriptBlock {
+ if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) {
+ $env:ATUIN_SESSION = atuin uuid
+ $env:ATUIN_PID = $PID
+ }
+
+ $script:atuinHistoryId = $null
+ $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine
+
+ # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available.
+ $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)")
+
+ function Get-CommandLine {
+ $commandLine = ""
+ [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null)
+ return $commandLine
+ }
+
+ function Set-CommandLine {
+ param([string]$Text)
+
+ $commandLine = Get-CommandLine
+ [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text)
+ }
+
+ # This function name is called by PSReadLine to read the next command line to execute.
+ # We replace it with a custom implementation which adds Atuin support.
+ function PSConsoleHostReadLine {
+ ## 1. Collect the exit code of the previous command.
+
+ # This needs to be done as the first thing because any script run will flush $?.
+ $lastRunStatus = $?
+
+ # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account.
+ $lastNativeExitCode = $global:LASTEXITCODE
+ $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 }
+
+ ## 2. Report the status of the previous command to Atuin (atuin history end).
+
+ if ($script:atuinHistoryId) {
+ try {
+ # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored.
+ $duration = (Get-History -Count 1).Duration.Ticks * 100
+ $durationArg = if ($duration) { "--duration=$duration" } else { $null }
+
+ # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync.
+ $process = New-Object System.Diagnostics.Process
+ $process.StartInfo.FileName = "atuin"
+ $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId"
+ $process.StartInfo.UseShellExecute = $false
+ $process.StartInfo.CreateNoWindow = $true
+ $process.StartInfo.RedirectStandardInput = $true
+ $process.StartInfo.RedirectStandardOutput = $true
+ $process.StartInfo.RedirectStandardError = $true
+ $process.Start() | Out-Null
+ $process.StandardInput.Close()
+ $process.BeginOutputReadLine()
+ $process.BeginErrorReadLine()
+ }
+ catch {
+ # Ignore errors to avoid breaking the shell.
+ # An error would occur if the user removes atuin from the PATH, for instance.
+ }
+ finally {
+ $script:atuinHistoryId = $null
+ }
+ }
+
+ ## 3. Read the next command line to execute.
+
+ # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions.
+ Microsoft.PowerShell.Core\Set-StrictMode -Off
+
+ $line = if ($script:hasExpectedReadLineOverload) {
+ # When the overload we expect is available, we can pass $lastRunStatus to it.
+ [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus)
+ } else {
+ # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is.
+ & $script:previousPSConsoleHostReadLine
+ }
+
+ ## 4. Report the next command line to Atuin (atuin history start).
+
+ # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version,
+ # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes.
+ # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions.
+ try {
+ $env:ATUIN_COMMAND_LINE = $line
+ $script:atuinHistoryId = atuin history start --command-from-env
+ }
+ catch {
+ # Ignore errors to avoid breaking the shell, see above.
+ }
+ finally {
+ $env:ATUIN_COMMAND_LINE = $null
+ }
+
+ $global:LASTEXITCODE = $lastNativeExitCode
+ return $line
+ }
+
+ function Invoke-AtuinSearch {
+ param([string]$ExtraArgs = "")
+
+ $previousOutputEncoding = [System.Console]::OutputEncoding
+ $resultFile = New-TemporaryFile
+ $suggestion = ""
+ $errorOutput = ""
+
+ try {
+ [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+
+ # Start-Process does some crazy stuff, just use the Process class directly to have more control.
+ $process = New-Object System.Diagnostics.Process
+ $process.StartInfo.FileName = "atuin"
+ $process.StartInfo.Arguments = "search -i --result-file ""$($resultFile.FullName)"" $ExtraArgs"
+ $process.StartInfo.UseShellExecute = $false
+ $process.StartInfo.RedirectStandardError = $true
+ $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8
+ $process.StartInfo.EnvironmentVariables["ATUIN_SHELL"] = "powershell"
+ $process.StartInfo.EnvironmentVariables["ATUIN_QUERY"] = Get-CommandLine
+ # PowerShell's Set-Location (cd) doesn't update the process-level working directory, set it explicitly
+ $process.StartInfo.WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath
+
+ try {
+ $process.Start() | Out-Null
+
+ # A single stream is redirected, so we can read it synchronously, but we have to start reading it
+ # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock.
+ $errorOutput = $process.StandardError.ReadToEnd().Trim()
+ $process.WaitForExit()
+
+ $suggestion = (Get-Content -LiteralPath $resultFile.FullName -Raw -Encoding UTF8 | Out-String).Trim()
+ }
+ catch {
+ $errorOutput = $_
+ }
+
+ if ($errorOutput) {
+ Write-Host -ForegroundColor Red "Atuin error:"
+ Write-Host -ForegroundColor DarkRed $errorOutput
+ }
+
+ # If no shell prompt offset is set, initialize it from the current prompt line count.
+ if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) {
+ try {
+ $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines
+ $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1)
+ }
+ catch {
+ $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0
+ }
+ }
+
+ # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode.
+ # Fortunately, InvokePrompt can receive a new Y position and reset the internal state.
+ $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET
+ $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0)
+ [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y)
+
+ if ($suggestion -eq "") {
+ # The previous input was already rendered by InvokePrompt
+ return
+ }
+
+ $acceptPrefix = "__atuin_accept__:"
+
+ if ( $suggestion.StartsWith($acceptPrefix)) {
+ Set-CommandLine $suggestion.Substring($acceptPrefix.Length)
+ [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
+ } else {
+ Set-CommandLine $suggestion
+ }
+ }
+ finally {
+ [System.Console]::OutputEncoding = $previousOutputEncoding
+ $resultFile.Delete()
+ }
+ }
+
+ function Enable-AtuinSearchKeys {
+ param([bool]$CtrlR = $true, [bool]$UpArrow = $true)
+
+ if ($CtrlR) {
+ Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock {
+ Invoke-AtuinSearch
+ }
+ }
+
+ if ($UpArrow) {
+ Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock {
+ $line = Get-CommandLine
+
+ if (!$line.Contains("`n")) {
+ Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding"
+ } else {
+ [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine()
+ }
+ }
+ }
+ }
+
+ $ExecutionContext.SessionState.Module.OnRemove += {
+ $env:ATUIN_SESSION = $null
+ $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine
+ }
+
+ Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine")
+} | Import-Module -Global
diff --git a/crates/client/src/shell/atuin.xsh b/crates/client/src/shell/atuin.xsh
new file mode 100644
index 00000000..a0283402
--- /dev/null
+++ b/crates/client/src/shell/atuin.xsh
@@ -0,0 +1,86 @@
+import os
+import subprocess
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.filters import Condition
+from prompt_toolkit.keys import Keys
+
+
+if "ATUIN_SESSION" not in ${...} or ${...}.get("ATUIN_SHLVL", "") != ${...}.get("SHLVL", ""):
+ $ATUIN_SESSION=$(atuin uuid).rstrip('\n')
+ $ATUIN_SHLVL = ${...}.get("SHLVL", "")
+
+@events.on_precommand
+def _atuin_precommand(cmd: str):
+ cmd = cmd.rstrip("\n")
+ try:
+ $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>@(os.devnull)).rstrip("\n")
+ except:
+ $ATUIN_HISTORY_ID = ""
+
+
+@events.on_postcommand
+def _atuin_postcommand(cmd: str, rtn: int, out, ts):
+ if "ATUIN_HISTORY_ID" not in ${...}:
+ return
+
+ duration = ts[1] - ts[0]
+ # Duration is float representing seconds, but atuin expects integer of nanoseconds
+ nanos = round(duration * 10 ** 9)
+ with ${...}.swap(ATUIN_LOG="error"):
+ # This causes the entire .xonshrc to be re-executed, which is incredibly slow
+ # This happens when using a subshell and using output redirection at the same time
+ # For more details, see https://github.com/xonsh/xonsh/issues/5224
+ # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1
+ atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > @(os.devnull) 2>&1
+ del $ATUIN_HISTORY_ID
+
+
+def _search(event, extra_args: list[str]):
+ buffer = event.current_buffer
+ cmd = ["atuin", "search", "--interactive", *extra_args]
+ # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters
+ env = ${...}.detype()
+ env["ATUIN_SHELL"] = "xonsh"
+ env["ATUIN_QUERY"] = buffer.text
+
+ p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env)
+ result = p.stderr.rstrip("\n")
+ # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen
+ event.cli.renderer.erase()
+
+ if not result:
+ return
+
+ buffer.reset()
+ if result.startswith("__atuin_accept__:"):
+ buffer.insert_text(result[17:])
+ buffer.validate_and_handle()
+ else:
+ buffer.insert_text(result)
+
+
+@events.on_ptk_create
+def _custom_keybindings(bindings, **kw):
+ if _ATUIN_BIND_CTRL_R:
+ @bindings.add(Keys.ControlR)
+ def r_search(event):
+ _search(event, extra_args=[])
+
+ if _ATUIN_BIND_UP_ARROW:
+ @Condition
+ def should_search():
+ buffer = get_app().current_buffer
+ # disable keybind when there is an active completion, so
+ # that up arrow can be used to navigate completion menu
+ if buffer.complete_state is not None:
+ return False
+ # similarly, disable when buffer text contains multiple lines
+ if '\n' in buffer.text:
+ return False
+
+ return True
+
+ @bindings.add(Keys.Up, filter=should_search)
+ def up_search(event):
+ _search(event, extra_args=["--shell-up-key-binding"])
diff --git a/crates/client/src/shell/atuin.zsh b/crates/client/src/shell/atuin.zsh
new file mode 100644
index 00000000..7e7fef27
--- /dev/null
+++ b/crates/client/src/shell/atuin.zsh
@@ -0,0 +1,167 @@
+# shellcheck disable=SC2034,SC2153,SC2086,SC2155
+
+# Above line is because shellcheck doesn't support zsh, per
+# https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in
+# ludeeus/action-shellcheck only supports _directories_, not _files_. So
+# instead, we manually add any error the shellcheck step finds in the file to
+# the above line ...
+
+# Source this in your ~/.zshrc
+autoload -U add-zsh-hook
+
+zmodload zsh/datetime 2>/dev/null
+
+# If zsh-autosuggestions is installed, configure it to use Atuin's search. If
+# you'd like to override this, then add your config after the $(atuin init zsh)
+# in your .zshrc
+_zsh_autosuggest_strategy_atuin() {
+ # silence errors, since we don't want to spam the terminal prompt while typing.
+ suggestion=$(ATUIN_QUERY="$1" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
+}
+
+if [ -n "${ZSH_AUTOSUGGEST_STRATEGY:-}" ]; then
+ ZSH_AUTOSUGGEST_STRATEGY=("atuin" "${ZSH_AUTOSUGGEST_STRATEGY[@]}")
+else
+ ZSH_AUTOSUGGEST_STRATEGY=("atuin")
+fi
+
+if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
+ export ATUIN_SESSION=$(atuin uuid)
+ export ATUIN_SHLVL=$SHLVL
+fi
+ATUIN_HISTORY_ID=""
+
+__atuin_osc133_command_executed() {
+ [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
+ [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
+
+ printf '\033]133;C\a'
+}
+
+__atuin_osc133_command_finished() {
+ [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
+ [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
+
+ printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
+}
+
+__atuin_osc133_prompt_start=$'%{\033]133;A;cl=line\a%}'
+__atuin_osc133_prompt_end=$'%{\033]133;B\a%}'
+
+__atuin_osc133_wrap_prompt() {
+ local __atuin_prompt="${PROMPT-}"
+ local __atuin_rprompt="${RPROMPT-}"
+
+ __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
+ __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
+ __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_start/}"
+ __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_end/}"
+
+ if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
+ PROMPT="${__atuin_osc133_prompt_start}${__atuin_prompt}"
+ RPROMPT="${__atuin_rprompt}${__atuin_osc133_prompt_end}"
+ else
+ PROMPT="$__atuin_prompt"
+ RPROMPT="$__atuin_rprompt"
+ fi
+}
+
+_atuin_preexec() {
+ local id
+ id=$(atuin history start -- "$1" 2>/dev/null)
+ export ATUIN_HISTORY_ID="$id"
+ __atuin_osc133_command_executed
+ __atuin_preexec_time=${EPOCHREALTIME-}
+}
+
+_atuin_precmd() {
+ local EXIT="$?" __atuin_precmd_time=${EPOCHREALTIME-}
+
+ __atuin_osc133_wrap_prompt
+
+ [[ -z "${ATUIN_HISTORY_ID:-}" ]] && return
+
+ local duration=""
+ if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then
+ printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000))
+ fi
+
+ __atuin_osc133_command_finished "$EXIT"
+ (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1
+ export ATUIN_HISTORY_ID=""
+}
+
+__atuin_search_cmd() {
+ local -a search_args=("$@")
+
+
+ ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
+}
+
+_atuin_search() {
+ emulate -L zsh
+ zle -I
+
+ # swap stderr and stdout, so that the tui stuff works
+ # TODO: not this
+ local output __atuin_status
+ # shellcheck disable=SC2048
+ output=$(__atuin_search_cmd $*)
+ __atuin_status=$?
+
+ zle reset-prompt
+ # re-enable bracketed paste
+ # shellcheck disable=SC2154
+ echo -n ${zle_bracketed_paste[1]} >/dev/tty
+
+ if (( __atuin_status != 0 )); then
+ [[ -n $output ]] && print -r -- "$output" >/dev/tty
+ return $__atuin_status
+ fi
+
+ if [[ -n $output ]]; then
+ RBUFFER=""
+ LBUFFER=$output
+
+ if [[ $LBUFFER == __atuin_accept__:* ]]
+ then
+ LBUFFER=${LBUFFER#__atuin_accept__:}
+ zle accept-line
+ fi
+ fi
+}
+_atuin_search_vicmd() {
+ _atuin_search --keymap-mode=vim-normal
+}
+_atuin_search_viins() {
+ _atuin_search --keymap-mode=vim-insert
+}
+
+_atuin_up_search() {
+ # Only trigger if the buffer is a single line
+ if [[ ! $BUFFER == *$'\n'* ]]; then
+ _atuin_search --shell-up-key-binding "$@"
+ else
+ zle up-line
+ fi
+}
+_atuin_up_search_vicmd() {
+ _atuin_up_search --keymap-mode=vim-normal
+}
+_atuin_up_search_viins() {
+ _atuin_up_search --keymap-mode=vim-insert
+}
+
+add-zsh-hook preexec _atuin_preexec
+add-zsh-hook precmd _atuin_precmd
+
+zle -N atuin-search _atuin_search
+zle -N atuin-search-vicmd _atuin_search_vicmd
+zle -N atuin-search-viins _atuin_search_viins
+zle -N atuin-up-search _atuin_up_search
+zle -N atuin-up-search-vicmd _atuin_up_search_vicmd
+zle -N atuin-up-search-viins _atuin_up_search_viins
+
+# These are compatibility widget names for "atuin <= 17.2.1" users.
+zle -N _atuin_search_widget _atuin_search
+zle -N _atuin_up_search_widget _atuin_up_search
diff --git a/crates/client/src/sync.rs b/crates/client/src/sync.rs
new file mode 100644
index 00000000..abe1a201
--- /dev/null
+++ b/crates/client/src/sync.rs
@@ -0,0 +1,34 @@
+use eyre::{Context, Result};
+
+use crate::atuin_client::database::ClientSqlite;
+use crate::atuin_client::{
+ history::store::HistoryStore, record::sqlite_store::SqliteStore, settings::Settings,
+};
+use crate::atuin_common::record::RecordId;
+
+// This is the only crate that ties together all other crates.
+// Therefore, it's the only crate where functions tying together all stores can live
+
+/// Rebuild all stores after a sync
+/// Note: for history, this only does an _incremental_ sync. Hence the need to specify downloaded
+/// records.
+pub(crate) async fn build(
+ settings: &Settings,
+ store: &SqliteStore,
+ db: &ClientSqlite,
+ downloaded: Option<&[RecordId]>,
+) -> Result<()> {
+ let encryption_key: [u8; 32] = crate::atuin_client::encryption::load_key(settings)
+ .context("could not load encryption key")?
+ .into();
+
+ let host_id = Settings::host_id().await?;
+
+ let downloaded = downloaded.unwrap_or(&[]);
+
+ let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+
+ history_store.incremental_build(db, downloaded).await?;
+
+ Ok(())
+}