aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
commit6bd2b80be51a8623640fbd77afa1da09289e40cc (patch)
tree8fa76fc78e27a4f3d6f57af07805a83e9877f0ad /crates
parentchore: Commit (diff)
downloadatuin-6bd2b80be51a8623640fbd77afa1da09289e40cc.zip
chore: All compiles
Diffstat (limited to 'crates')
-rw-r--r--crates/client/Cargo.toml2
-rw-r--r--crates/client/src/atuin_client/mod.rs2
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs17
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs573
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs260
-rw-r--r--crates/client/src/atuin_history/mod.rs1
-rw-r--r--crates/client/src/atuin_history/sort.rs46
-rw-r--r--crates/client/src/atuin_history/stats.rs28
-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
-rw-r--r--crates/client/src/command/client.rs28
-rw-r--r--crates/client/src/command/client/daemon.rs44
-rw-r--r--crates/client/src/command/client/history.rs1241
-rw-r--r--crates/client/src/command/client/history/end.rs38
-rw-r--r--crates/client/src/command/client/history/mod.rs753
-rw-r--r--crates/client/src/command/client/history/start.rs76
-rw-r--r--crates/client/src/command/client/history/tail.rs321
-rw-r--r--crates/client/src/command/client/info.rs24
-rw-r--r--crates/client/src/command/client/stats.rs18
-rw-r--r--crates/client/src/command/client/store/mod.rs (renamed from crates/client/src/command/client/store.rs)0
-rw-r--r--crates/client/src/command/client/sync.rs149
-rw-r--r--crates/client/src/command/client/sync/status.rs25
-rw-r--r--crates/client/src/command/client/wrapped.rs17
-rw-r--r--crates/client/src/command/mod.rs86
-rw-r--r--crates/client/src/main.rs10
-rw-r--r--crates/client/src/print_error.rs123
-rw-r--r--crates/client/src/sync.rs34
-rw-r--r--crates/daemon/proto/control.proto10
-rw-r--r--crates/daemon/proto/history.proto17
-rw-r--r--crates/daemon/src/aclient/database/mod.rs7
-rw-r--r--crates/daemon/src/aclient/history/builder.rs2
-rw-r--r--crates/daemon/src/aclient/history/mod.rs42
-rw-r--r--crates/daemon/src/aclient/history/store.rs90
-rw-r--r--crates/daemon/src/api/client/mod.rs84
-rw-r--r--crates/daemon/src/api/server/control.rs18
-rw-r--r--crates/daemon/src/api/server/history.rs88
-rw-r--r--crates/daemon/src/lib.rs15
42 files changed, 1618 insertions, 4627 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index 6d26a282..964c9728 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -12,6 +12,8 @@ homepage = { workspace = true }
repository = { workspace = true }
[dependencies]
+turtle-daemon = {workspace = true}
+turtle-common = {workspace = true}
async-trait = "0.1.58"
axum = "0.8"
base64 = "0.22"
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs
index 0678d8b7..fa40d8dd 100644
--- a/crates/client/src/atuin_client/mod.rs
+++ b/crates/client/src/atuin_client/mod.rs
@@ -1,2 +1,2 @@
pub(crate) mod settings;
-pub(crate) mod theme;
+// pub(crate) mod theme;
diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs
index 7993ef6d..e69de29b 100644
--- a/crates/client/src/atuin_client/settings/meta.rs
+++ b/crates/client/src/atuin_client/settings/meta.rs
@@ -1,17 +0,0 @@
-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 = turtle_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/mod.rs b/crates/client/src/atuin_client/settings/mod.rs
index 0bddc09c..0eb228b9 100644
--- a/crates/client/src/atuin_client/settings/mod.rs
+++ b/crates/client/src/atuin_client/settings/mod.rs
@@ -1,12 +1,5 @@
-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 std::{collections::HashMap, fmt, path::PathBuf, str::FromStr, sync::OnceLock};
-use crate::aclient::encryption::decode_key;
use clap::ValueEnum;
use config::{
Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
@@ -16,94 +9,11 @@ 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};
-use turtle_common::record::HostId;
+use time::{UtcOffset, format_description::FormatItem, macros::format_description};
+use tracing::info;
use turtle_common::utils;
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
-static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
-static META_STORE: OnceCell<crate::aclient::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 {
@@ -389,38 +299,11 @@ pub(crate) struct Preview {
#[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`].
@@ -481,14 +364,6 @@ pub(crate) struct Logs {
/// 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 {
@@ -502,11 +377,8 @@ impl Default for Preview {
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,
}
}
}
@@ -518,14 +390,6 @@ impl Default for Logs {
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()
- },
}
}
}
@@ -538,61 +402,6 @@ impl Logs {
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.
@@ -611,246 +420,6 @@ pub(crate) enum PreviewStrategy {
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 struct Settings {
@@ -859,15 +428,7 @@ pub struct Settings {
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,
@@ -906,9 +467,6 @@ pub struct Settings {
pub(crate) command_chaining: bool,
#[serde(default)]
- pub(crate) sync: Sync,
-
- #[serde(default)]
pub(crate) stats: Stats,
#[serde(default)]
@@ -924,61 +482,10 @@ pub struct Settings {
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::aclient::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::aclient::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())
}
@@ -989,13 +496,11 @@ impl Settings {
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}")?
@@ -1068,8 +573,6 @@ impl Settings {
.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)?
@@ -1295,14 +798,6 @@ impl Settings {
.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)
}
@@ -1311,12 +806,6 @@ impl Settings {
.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 {
@@ -1381,60 +870,6 @@ mod tests {
}
#[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;
@@ -1447,7 +882,6 @@ mod tests {
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")?;
@@ -1462,7 +896,6 @@ mod tests {
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,
turtle_common::utils::runtime_dir()
diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs
deleted file mode 100644
index 01d20855..00000000
--- a/crates/client/src/atuin_client/settings/watcher.rs
+++ /dev/null
@@ -1,260 +0,0 @@
-//! 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::aclient::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(|_| turtle_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_history/mod.rs b/crates/client/src/atuin_history/mod.rs
index 41336a14..b3ca0d2f 100644
--- a/crates/client/src/atuin_history/mod.rs
+++ b/crates/client/src/atuin_history/mod.rs
@@ -1,2 +1 @@
-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
deleted file mode 100644
index 3143fa68..00000000
--- a/crates/client/src/atuin_history/sort.rs
+++ /dev/null
@@ -1,46 +0,0 @@
-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
index c53dafb2..462fe077 100644
--- a/crates/client/src/atuin_history/stats.rs
+++ b/crates/client/src/atuin_history/stats.rs
@@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet};
use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
use serde::{Deserialize, Serialize};
+use turtle_daemon::aclient::history::History;
use unicode_segmentation::UnicodeSegmentation;
-use crate::atuin_client::{history::History, settings::Settings};
+use crate::atuin_client::settings::Settings;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Stats {
@@ -278,9 +279,9 @@ pub(crate) fn compute(
#[cfg(test)]
mod tests {
- use crate::atuin_client::history::History;
use crate::atuin_client::settings::Settings;
use time::OffsetDateTime;
+ use turtle_daemon::aclient::history::History;
use super::compute;
use super::{interesting_command, split_at_pipe, strip_leading_env_vars};
@@ -301,29 +302,6 @@ mod tests {
}
#[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();
diff --git a/crates/client/src/atuin_pty_proxy/capture.rs b/crates/client/src/atuin_pty_proxy/capture.rs
deleted file mode 100644
index 55bc1788..00000000
--- a/crates/client/src/atuin_pty_proxy/capture.rs
+++ /dev/null
@@ -1,470 +0,0 @@
-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
deleted file mode 100644
index c2a1691c..00000000
--- a/crates/client/src/atuin_pty_proxy/debug.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-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
deleted file mode 100644
index e1d01c83..00000000
--- a/crates/client/src/atuin_pty_proxy/mod.rs
+++ /dev/null
@@ -1,17 +0,0 @@
-#[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
deleted file mode 100644
index f2a796f1..00000000
--- a/crates/client/src/atuin_pty_proxy/osc133.rs
+++ /dev/null
@@ -1,899 +0,0 @@
-//! 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
deleted file mode 100644
index ef4b0c37..00000000
--- a/crates/client/src/atuin_pty_proxy/pty_proxy.rs
+++ /dev/null
@@ -1,237 +0,0 @@
-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
deleted file mode 100644
index 69b3a075..00000000
--- a/crates/client/src/atuin_pty_proxy/runtime.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-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
deleted file mode 100644
index 58ebd2eb..00000000
--- a/crates/client/src/atuin_pty_proxy/screen.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-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/client.rs b/crates/client/src/command/client.rs
index 42e72e21..9f45f53b 100644
--- a/crates/client/src/command/client.rs
+++ b/crates/client/src/command/client.rs
@@ -37,11 +37,12 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
}
mod config;
+mod daemon;
mod default_config;
mod history;
mod info;
mod stats;
-mod store;
+// mod store;
mod sync;
mod wrapped;
@@ -52,14 +53,17 @@ pub(crate) enum Cmd {
#[command(subcommand)]
History(history::Cmd),
+ /// Interact with the daemon
#[command(subcommand)]
- /// Request a sync or view sync status
- Sync(sync::Cmd),
+ Daemon(daemon::Cmd),
- /// Manage the atuin data store
#[command(subcommand)]
- Store(store::Cmd),
+ /// Request a sync or view sync status
+ Sync(sync::Cmd),
+ // /// Manage the atuin data store
+ // #[command(subcommand)]
+ // Store(store::Cmd),
/// Information about dotfiles locations and ENV vars
#[command()]
Info,
@@ -97,7 +101,7 @@ impl Cmd {
res
}
- async fn run_inner(self, mut settings: Settings) -> Result<()> {
+ async fn run_inner(self, settings: Settings) -> Result<()> {
// ATUIN_LOG env var overrides config file level settings
let env_log_set = std::env::var("ATUIN_LOG").is_ok();
@@ -117,14 +121,14 @@ impl Cmd {
}
match self {
- Self::Stats(stats) => stats.run(&db, &settings).await,
- Self::Wrapped { year } => wrapped::run(year, &db, &settings).await,
-
- Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,
+ Self::Daemon(cmd) => cmd.run(&settings).await,
+ Self::Sync(sync) => sync.run(settings).await,
- Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
+ Self::Stats(stats) => stats.run(&settings).await,
+ Self::Wrapped { year } => wrapped::run(year, &settings).await,
- Self::Info => info::run(&settings),
+ // Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
+ Self::Info => info::run(&settings).await,
Self::DefaultConfig => {
default_config::run();
diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs
new file mode 100644
index 00000000..83877cfd
--- /dev/null
+++ b/crates/client/src/command/client/daemon.rs
@@ -0,0 +1,44 @@
+use clap::Subcommand;
+use eyre::Result;
+
+use turtle_daemon::api::client::{Probe, probe};
+
+use crate::atuin_client::settings::Settings;
+
+#[derive(Subcommand, Debug)]
+#[command(infer_subcommands = true)]
+pub(crate) enum Cmd {
+ /// Show the daemon's current status
+ Status,
+}
+
+impl Cmd {
+ pub(crate) async fn run(self, settings: &Settings) -> Result<()> {
+ match self {
+ Cmd::Status => status_cmd(&settings).await,
+ }
+ }
+}
+
+async fn status_cmd(settings: &Settings) -> Result<()> {
+ match probe(settings.daemon.socket_path.clone()).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);
+ 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(())
+}
diff --git a/crates/client/src/command/client/history.rs b/crates/client/src/command/client/history.rs
deleted file mode 100644
index 4d633b7d..00000000
--- a/crates/client/src/command/client/history.rs
+++ /dev/null
@@ -1,1241 +0,0 @@
-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());
- }
-}
-
-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),
- }
-}
-
-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),
- }
-}
-
-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/history/end.rs b/crates/client/src/command/client/history/end.rs
new file mode 100644
index 00000000..bfec0aab
--- /dev/null
+++ b/crates/client/src/command/client/history/end.rs
@@ -0,0 +1,38 @@
+use crate::atuin_client::settings::Settings;
+
+use eyre::{Result, eyre};
+use turtle_daemon::api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message};
+
+pub(super) async fn handle(
+ settings: &Settings,
+ id: &str,
+ exit: i64,
+ duration: Option<u64>,
+) -> Result<()> {
+ end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
+
+ Ok(())
+}
+
+async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> {
+ match async {
+ HistoryClient::new(settings.daemon.socket_path.clone())
+ .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),
+ }
+}
diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs
new file mode 100644
index 00000000..d71c653d
--- /dev/null
+++ b/crates/client/src/command/client/history/mod.rs
@@ -0,0 +1,753 @@
+use std::{
+ fmt::{self, Display},
+ io::{self, IsTerminal, Write},
+ ops::ControlFlow,
+ time::Duration,
+};
+
+use clap::Subcommand;
+use eyre::Result;
+use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
+use time::{OffsetDateTime, macros::format_description};
+use turtle_common::utils::Escapable;
+use turtle_daemon::aclient::history::History;
+
+use crate::atuin_client::settings::{Settings, Timezone};
+
+mod end;
+mod start;
+mod tail;
+
+#[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,
+ },
+}
+
+impl Cmd {
+ // #[expect(clippy::too_many_arguments)]
+ // #[expect(clippy::fn_params_excessive_bools)]
+ // async fn handle_list(
+ // settings: &Settings,
+ // 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) =
+ start::handle(settings, &command, author.as_deref(), intent.as_deref()).await?
+ {
+ println!("{id}");
+ }
+
+ Ok(())
+ }
+ Self::End { id, exit, duration } => end::handle(settings, &id, exit, duration).await,
+ Self::Tail => {
+ return tail::handle(settings).await;
+ }
+ cmd => {
+ todo!()
+ // 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!(),
+ // }
+ }
+ }
+ }
+}
+
+#[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,
+}
+/// 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 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"),
+ }
+}
+
+#[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]");
+
+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
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Settings, 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());
+ }
+}
diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs
new file mode 100644
index 00000000..81aaa904
--- /dev/null
+++ b/crates/client/src/command/client/history/start.rs
@@ -0,0 +1,76 @@
+use crate::{
+ atuin_client::settings::Settings,
+ command::client::history::{apply_start_metadata, normalize_command_for_storage},
+};
+
+use eyre::{Result, eyre};
+use time::OffsetDateTime;
+use tracing::debug;
+use turtle_common::utils;
+use turtle_daemon::{
+ aclient::history::{History, SettingsFilter},
+ api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message},
+};
+
+pub(super) async fn handle(
+ 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(SettingsFilter {
+ history: &settings.history_filter,
+ cwd: &settings.cwd_filter,
+ secrets: settings.secrets_filter,
+ }) {
+ 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 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 start_history(settings: &Settings, history: History) -> Result<String> {
+ match async {
+ HistoryClient::new(settings.daemon.socket_path.clone())
+ .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),
+ }
+}
diff --git a/crates/client/src/command/client/history/tail.rs b/crates/client/src/command/client/history/tail.rs
new file mode 100644
index 00000000..595fe3a0
--- /dev/null
+++ b/crates/client/src/command/client/history/tail.rs
@@ -0,0 +1,321 @@
+use crate::{
+ atuin_client::settings::{Settings, Timezone},
+ command::client::history::{TIME_FMT, format_duration_into},
+};
+
+use colored::Colorize;
+use eyre::{Context, Result, bail};
+use serde::Serialize;
+use time::OffsetDateTime;
+use turtle_common::utils::Escapable;
+use turtle_daemon::{
+ aclient::history::History,
+ api::client::{
+ HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe,
+ },
+};
+
+use std::{
+ fmt::{self, Display},
+ io::{self, IsTerminal, Write},
+ time::Duration,
+};
+
+#[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 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_entry_to_history(history),
+ })
+ }
+
+ 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 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 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 format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
+ Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?)
+}
+
+async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
+ match probe(settings.daemon.socket_path.clone()).await {
+ Probe::Ready(_) => HistoryClient::new(settings.daemon.socket_path.clone()).await,
+ Probe::NeedsRestart(reason) => {
+ bail!("{reason}. Restart the daemon manually");
+ }
+ Probe::Unreachable(err) => Err(err),
+ }
+}
+
+pub(super) async fn handle(settings: &Settings) -> Result<()> {
+ let tty = io::stdout().is_terminal();
+ let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).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(())
+}
diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs
index 49c92193..77c7064c 100644
--- a/crates/client/src/command/client/info.rs
+++ b/crates/client/src/command/client/info.rs
@@ -2,21 +2,33 @@ use crate::atuin_client::settings::Settings;
use crate::{SHA, VERSION};
use eyre::Result;
+use turtle_daemon::api::client::ControlClient;
+
+pub(crate) async fn run(settings: &Settings) -> Result<()> {
+ let config = turtle_common::utils::config_dir();
+
+ let mut client = ControlClient::new(settings.daemon.socket_path.clone()).await?;
+ let paths = client.paths().await?;
-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 files:
+ client config: {:?}
+ server config: {:?}
+ deamon config: {:?}
+ deamon db path: {:?}
+ deamon socket path: {:?}\
+ ",
config_file.to_string_lossy(),
sever_config.to_string_lossy(),
- settings.db_path,
- settings.sync.encryption_key()?,
- settings.meta.db_path
+ paths.config,
+ paths.db,
+ paths.socket,
);
let env_vars = format!(
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
index 9b8ebdff..ec235f36 100644
--- a/crates/client/src/command/client/stats.rs
+++ b/crates/client/src/command/client/stats.rs
@@ -2,10 +2,12 @@ use clap::Parser;
use eyre::Result;
use interim::parse_date_string;
use time::{Duration, OffsetDateTime, Time};
+use turtle_daemon::api::client::{HistoryClient, Range};
use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{compute, pretty_print};
+use crate::command::current_session;
fn parse_ngram_size(s: &str) -> Result<usize, String> {
let value = s
@@ -36,7 +38,9 @@ pub(crate) struct Cmd {
impl Cmd {
pub(crate) async fn run(&self, settings: &Settings) -> Result<()> {
- let context = current_context().await?;
+ let session = current_session()?;
+ let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
+
let words = if self.period.is_empty() {
String::from("all")
} else {
@@ -47,27 +51,27 @@ impl Cmd {
let last_night = now.replace_time(Time::MIDNIGHT);
let history = if words.as_str() == "all" {
- db.list(&[], &context, None, false, false).await?
+ client.history(session, None).await?
} else if words.trim() == "today" {
let start = last_night;
let end = start + Duration::days(1);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "month" {
let end = last_night;
let start = end - Duration::days(31);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "week" {
let end = last_night;
let start = end - Duration::days(7);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "year" {
let end = last_night;
let start = end - Duration::days(365);
- db.range(start, end).await?
+ client.history(session, Some(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?
+ client.history(session, Some(Range { start, end })).await?
};
let stats = compute(settings, &history, self.count, self.ngram_size);
diff --git a/crates/client/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs
index bc57488d..bc57488d 100644
--- a/crates/client/src/command/client/store.rs
+++ b/crates/client/src/command/client/store/mod.rs
diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs
index c29a82fc..86228e47 100644
--- a/crates/client/src/command/client/sync.rs
+++ b/crates/client/src/command/client/sync.rs
@@ -1,29 +1,18 @@
use clap::Subcommand;
-use eyre::{Result, WrapErr};
+use colored::Colorize;
+use eyre::{Result, WrapErr, bail};
use serde_json::json;
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- encryption,
- history::store::HistoryStore,
- record::{sqlite_store::SqliteStore, sync},
- settings::Settings,
- },
- atuin_common::utils,
-};
+use turtle_common::utils;
+use turtle_daemon::api::client::{Probe, probe};
-mod status;
+use crate::{SHA, VERSION, atuin_client::settings::Settings};
#[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,
- },
+ Perform {},
/// Print (or generate) the encryption key and user id for transfer to another machine
KeyAndId {},
@@ -33,88 +22,72 @@ pub(crate) enum Cmd {
}
impl Cmd {
- pub(crate) async fn run(
- self,
- settings: Settings,
- db: &ClientSqlite,
- store: SqliteStore,
- ) -> Result<()> {
+ pub(crate) async fn run(self, settings: Settings) -> Result<()> {
match self {
- Self::Perform { force } => run(&settings, force, db, store).await,
- Self::Status => status::run(&settings).await,
+ Self::Perform {} => perform_cmd(&settings).await,
+ Self::Status => status_cmd(&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(())
+ todo!()
+ // 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?;
+async fn status_cmd(settings: &Settings) -> Result<()> {
+ todo!();
- #[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...");
+ // 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");
+ // }
- // 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?;
+ Ok(())
+}
- println!("{uploaded}/{} up/down to record store", downloaded.len());
+async fn perform_cmd(settings: &Settings) -> Result<()> {
+ match probe(settings.daemon.socket_path.clone()).await {
+ Probe::Ready(mut control_client) => {
+ let reply = control_client.force_sync().await?;
+ if !reply.accepted {
+ bail!("Daemon refused to accept sync request");
+ }
+ }
+ Probe::NeedsRestart(msg) => {
+ bail!("Daemon version mis-match, needs restart: {msg}");
+ }
+ Probe::Unreachable(report) => {
+ bail!("Daemon unreachable: {report}");
+ }
}
- 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
deleted file mode 100644
index caf3b90f..00000000
--- a/crates/client/src/command/client/sync/status.rs
+++ /dev/null
@@ -1,25 +0,0 @@
-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
index 2ce19bf7..64e5b718 100644
--- a/crates/client/src/command/client/wrapped.rs
+++ b/crates/client/src/command/client/wrapped.rs
@@ -2,11 +2,13 @@ use crossterm::style::{ResetColor, SetAttribute};
use eyre::Result;
use std::collections::{HashMap, HashSet};
use time::{Date, Duration, Month, OffsetDateTime, Time};
+use turtle_daemon::aclient::history::History;
+use turtle_daemon::api::client::{HistoryClient, Range};
-use crate::atuin_client::database::ClientSqlite;
use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{Stats, compute};
+use crate::command::current_session;
#[derive(Debug)]
struct WrappedStats {
@@ -21,11 +23,7 @@ struct WrappedStats {
impl WrappedStats {
#[expect(clippy::too_many_lines, clippy::cast_precision_loss)]
- fn new(
- settings: &Settings,
- stats: &Stats,
- history: &[crate::atuin_client::history::History],
- ) -> Self {
+ fn new(settings: &Settings, stats: &Stats, history: &[History]) -> Self {
let nav_commands = stats
.top
.iter()
@@ -272,7 +270,10 @@ fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
println!();
}
-pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> {
+pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> {
+ let session = current_session()?;
+ let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
+
let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
let month = now.month();
@@ -296,7 +297,7 @@ pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Setting
now.offset(),
);
- let history = db.range(start, end).await?;
+ let history = client.history(session, Some(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 🪄"
diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs
index 9a648254..2e51a1e2 100644
--- a/crates/client/src/command/mod.rs
+++ b/crates/client/src/command/mod.rs
@@ -15,10 +15,6 @@ 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,
@@ -41,17 +37,12 @@ impl AtuinCmd {
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());
+ println!("{}", turtle_common::utils::uuid_v7().as_simple());
Ok(())
}
Self::GenCompletions(gen_completions) => gen_completions.run(),
@@ -60,52 +51,6 @@ impl AtuinCmd {
}
#[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)
@@ -113,29 +58,8 @@ fn is_truthy_env(name: &str) -> bool {
.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);
- }
+pub(crate) fn current_session() -> Result<String> {
+ std::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.")
+ })
}
diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs
index 2453c0e1..51480a7b 100644
--- a/crates/client/src/main.rs
+++ b/crates/client/src/main.rs
@@ -4,12 +4,6 @@
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;
@@ -22,10 +16,6 @@ mod command;
pub(crate) mod atuin_client;
pub(crate) mod atuin_history;
-pub(crate) mod atuin_pty_proxy;
-
-mod print_error;
-mod sync;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const SHA: &str = env!("GIT_HASH");
diff --git a/crates/client/src/print_error.rs b/crates/client/src/print_error.rs
deleted file mode 100644
index 0a6303dd..00000000
--- a/crates/client/src/print_error.rs
+++ /dev/null
@@ -1,123 +0,0 @@
-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/sync.rs b/crates/client/src/sync.rs
deleted file mode 100644
index abe1a201..00000000
--- a/crates/client/src/sync.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-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(())
-}
diff --git a/crates/daemon/proto/control.proto b/crates/daemon/proto/control.proto
index 47a7b8b2..a8026cb8 100644
--- a/crates/daemon/proto/control.proto
+++ b/crates/daemon/proto/control.proto
@@ -7,6 +7,9 @@ service Control {
// Query the daemon for it's status.
rpc Status(StatusRequest) returns (StatusReply);
+
+ // Query the daemon about used paths.
+ rpc Paths(PathsRequest) returns (PathsReply);
}
message ForceSyncRequest {}
@@ -21,3 +24,10 @@ message StatusReply {
uint32 pid = 3;
uint32 protocol = 4;
}
+
+message PathsRequest {}
+message PathsReply {
+ string config = 1;
+ string db = 2;
+ string socket = 3;
+}
diff --git a/crates/daemon/proto/history.proto b/crates/daemon/proto/history.proto
index 46a41a5e..850b16b9 100644
--- a/crates/daemon/proto/history.proto
+++ b/crates/daemon/proto/history.proto
@@ -4,7 +4,11 @@ package history;
service History {
rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply);
rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply);
+
rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply);
+
+ // Request history from the daemon
+ rpc History(HistoryRequest) returns (HistoryReply);
}
message StartHistoryRequest {
@@ -59,3 +63,16 @@ message HistoryEntry {
int64 exit = 9;
int64 duration = 10;
}
+
+message Range {
+ uint64 start = 1;
+ uint64 end = 2;
+}
+
+message HistoryRequest {
+ string session = 1;
+ optional Range range = 2;
+}
+message HistoryReply {
+ repeated HistoryEntry entries = 1;
+}
diff --git a/crates/daemon/src/aclient/database/mod.rs b/crates/daemon/src/aclient/database/mod.rs
index 36049f80..5e25a0e9 100644
--- a/crates/daemon/src/aclient/database/mod.rs
+++ b/crates/daemon/src/aclient/database/mod.rs
@@ -51,10 +51,9 @@ pub(crate) struct OptFilters {
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.")
- })?;
+pub(crate) async fn current_context(session: String) -> eyre::Result<Context> {
+ // TODO(@bpeetz): More of this needs to be moved to the client <2026-07-20>
+
let hostname = get_host_user();
let cwd = utils::get_current_dir();
let host_id = Settings::host_id().await?;
diff --git a/crates/daemon/src/aclient/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs
index daa4ef49..ef52637b 100644
--- a/crates/daemon/src/aclient/history/builder.rs
+++ b/crates/daemon/src/aclient/history/builder.rs
@@ -49,7 +49,7 @@ impl From<HistoryImported> for History {
/// 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 {
+pub struct HistoryCaptured {
timestamp: time::OffsetDateTime,
#[builder(setter(into))]
command: String,
diff --git a/crates/daemon/src/aclient/history/mod.rs b/crates/daemon/src/aclient/history/mod.rs
index 09d24169..d61c95c5 100644
--- a/crates/daemon/src/aclient/history/mod.rs
+++ b/crates/daemon/src/aclient/history/mod.rs
@@ -1,4 +1,5 @@
use core::fmt::Formatter;
+use regex::RegexSet;
use rmp::decode::DecodeStringError;
use rmp::decode::ValueReadError;
use rmp::{Marker, decode::Bytes};
@@ -28,7 +29,7 @@ const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
-pub struct HistoryId(pub(crate) String);
+pub struct HistoryId(pub String);
impl Display for HistoryId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
@@ -60,37 +61,37 @@ pub struct History {
/// A client-generated ID, used to identify the entry when syncing.
///
/// Stored as `client_id` in the database.
- pub(crate) id: HistoryId,
+ pub id: HistoryId,
/// When the command was run.
- pub(crate) timestamp: OffsetDateTime,
+ pub timestamp: OffsetDateTime,
/// How long the command took to run.
- pub(crate) duration: i64,
+ pub duration: i64,
/// The exit code of the command.
- pub(crate) exit: i64,
+ pub exit: i64,
/// The command that was run.
- pub(crate) command: String,
+ pub command: String,
/// The current working directory when the command was run.
- pub(crate) cwd: String,
+ pub cwd: String,
/// The session ID, associated with a terminal session.
- pub(crate) session: String,
+ pub session: String,
/// The hostname of the machine the command was run on.
- pub(crate) hostname: String,
+ pub hostname: String,
/// Who wrote this command (human user or automation/agent identity).
- pub(crate) author: String,
+ pub author: String,
/// Optional rationale for why the command was executed.
- pub(crate) intent: Option<String>,
+ pub intent: Option<String>,
/// Timestamp, which is set when the entry is deleted, allowing a soft delete.
- pub(crate) deleted_at: Option<OffsetDateTime>,
+ pub deleted_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
@@ -397,7 +398,7 @@ impl History {
/// .build()
/// .into();
/// ```
- pub(crate) fn capture() -> builder::HistoryCapturedBuilder {
+ pub fn capture() -> builder::HistoryCapturedBuilder {
builder::HistoryCaptured::builder()
}
@@ -473,14 +474,21 @@ impl History {
self.exit == 0 || self.duration == -1
}
- pub(crate) fn should_save(&self, settings: &Settings) -> bool {
+ pub fn should_save(&self, filter: SettingsFilter<'_>) -> 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)))
+ || filter.history.is_match(&self.command)
+ || filter.cwd.is_match(&self.cwd)
+ || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command)))
}
}
+#[derive(Debug, Copy, Clone)]
+pub struct SettingsFilter<'a> {
+ pub history: &'a RegexSet,
+ pub cwd: &'a RegexSet,
+ pub secrets: bool,
+}
+
#[cfg(test)]
mod tests {
use regex::RegexSet;
diff --git a/crates/daemon/src/aclient/history/store.rs b/crates/daemon/src/aclient/history/store.rs
index db692590..a749a1a8 100644
--- a/crates/daemon/src/aclient/history/store.rs
+++ b/crates/daemon/src/aclient/history/store.rs
@@ -313,50 +313,52 @@ impl HistoryStore {
}
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));
+ todo!();
- 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");
+ // 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(())
}
@@ -364,8 +366,8 @@ impl HistoryStore {
#[cfg(test)]
mod tests {
- use turtle_common::record::DecryptedData;
use time::macros::datetime;
+ use turtle_common::record::DecryptedData;
use crate::aclient::history::{HISTORY_VERSION, store::HistoryRecord};
diff --git a/crates/daemon/src/api/client/mod.rs b/crates/daemon/src/api/client/mod.rs
index 71fa0e37..c588fb09 100644
--- a/crates/daemon/src/api/client/mod.rs
+++ b/crates/daemon/src/api/client/mod.rs
@@ -1,4 +1,5 @@
use eyre::{Context as EyreContext, Result};
+use time::OffsetDateTime;
use tonic::Code;
use tonic::transport::{Channel, Endpoint, Uri};
use tower::service_fn;
@@ -8,8 +9,11 @@ use hyper_util::rt::TokioIo;
#[cfg(unix)]
use tokio::net::UnixStream;
+use crate::api::generated;
+use crate::api::generated::control::{ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest};
+use crate::api::generated::history::{HistoryEntry, HistoryRequest};
use crate::{
- aclient::{history::History, settings::Settings},
+ aclient::history::History,
api::{
DAEMON_PROTOCOL_VERSION, DAEMON_VERSION,
generated::{
@@ -18,18 +22,49 @@ use crate::{
},
history::{
EndHistoryReply, EndHistoryRequest, StartHistoryReply, StartHistoryRequest,
- TailHistoryReply, TailHistoryRequest,
- history_client::HistoryClient as HistoryServiceClient,
+ TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient,
},
},
},
};
-fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
+pub use crate::api::generated::history::{HistoryEventKind, TailHistoryReply};
+
+fn normalize_optional_field(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+pub fn history_entry_to_history(entry: HistoryEntry) -> History {
+ let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp))
+ .expect("Daemon history timestamp should always be valid");
+
+ History {
+ id: entry.id.into(),
+ timestamp,
+ duration: entry.duration,
+ exit: entry.exit,
+ command: entry.command,
+ cwd: entry.cwd,
+ session: entry.session,
+ hostname: entry.hostname,
+ author: entry.author,
+ intent: normalize_optional_field(&entry.intent),
+ deleted_at: None,
+ }
+}
+
+#[must_use]
+pub 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 {
+#[must_use]
+pub 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 {
@@ -99,6 +134,11 @@ pub struct HistoryClient {
client: HistoryServiceClient<Channel>,
}
+pub struct Range {
+ pub start: OffsetDateTime,
+ pub end: OffsetDateTime,
+}
+
// Wrap the grpc client
impl HistoryClient {
#[cfg(unix)]
@@ -141,6 +181,24 @@ impl HistoryClient {
Ok(self.client.start_history(req).await?.into_inner())
}
+ pub async fn history(&mut self, session: String, range: Option<Range>) -> Result<Vec<History>> {
+ let req = HistoryRequest {
+ session,
+ range: range.map(|r| generated::history::Range {
+ start: r.start.unix_timestamp() as u64,
+ end: r.end.unix_timestamp() as u64,
+ }),
+ };
+
+ let reply = self.client.history(req).await?.into_inner();
+
+ Ok(reply
+ .entries
+ .into_iter()
+ .map(history_entry_to_history)
+ .collect())
+ }
+
pub async fn end_history(
&mut self,
id: String,
@@ -152,7 +210,7 @@ impl HistoryClient {
Ok(self.client.end_history(req).await?.into_inner())
}
- pub(crate) async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
+ pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
Ok(self
.client
.tail_history(TailHistoryRequest {})
@@ -196,10 +254,16 @@ impl ControlClient {
Ok(Self { client })
}
- /// Connect using settings.
- #[cfg(unix)]
- pub async fn from_settings(settings: &Settings) -> Result<Self> {
- Self::new(settings.daemon.socket_path.clone()).await
+ pub async fn paths(&mut self) -> Result<PathsReply> {
+ Ok(self.client.paths(PathsRequest {}).await?.into_inner())
+ }
+
+ pub async fn force_sync(&mut self) -> Result<ForceSyncReply> {
+ Ok(self
+ .client
+ .force_sync(ForceSyncRequest {})
+ .await?
+ .into_inner())
}
pub async fn status(&mut self) -> Result<StatusReply> {
diff --git a/crates/daemon/src/api/server/control.rs b/crates/daemon/src/api/server/control.rs
index 8d1ec7b8..a5e26355 100644
--- a/crates/daemon/src/api/server/control.rs
+++ b/crates/daemon/src/api/server/control.rs
@@ -11,7 +11,7 @@ use crate::{
api::{
DAEMON_PROTOCOL_VERSION, DAEMON_VERSION,
generated::control::{
- ForceSyncReply, ForceSyncRequest, StatusReply, StatusRequest,
+ ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest,
control_server::{Control, ControlServer},
},
},
@@ -58,6 +58,22 @@ impl ControlService {
#[tonic::async_trait]
impl Control for ControlService {
#[instrument(skip_all, level = Level::INFO)]
+ async fn paths(&self, _request: Request<PathsRequest>) -> Result<Response<PathsReply>, Status> {
+ let settings = self.handle.settings().await;
+
+ let config = Settings::get_config_path()
+ .map_err(|e| Status::internal(format!("failed to get settings path: {e:?}")))?;
+
+ let reply = PathsReply {
+ config: config.to_string_lossy().to_string(),
+ db: settings.db_path.clone(),
+ socket: settings.daemon.socket_path.clone(),
+ };
+
+ Ok(Response::new(reply))
+ }
+
+ #[instrument(skip_all, level = Level::INFO)]
async fn status(
&self,
_request: Request<StatusRequest>,
diff --git a/crates/daemon/src/api/server/history.rs b/crates/daemon/src/api/server/history.rs
index 989c7895..0edf3b94 100644
--- a/crates/daemon/src/api/server/history.rs
+++ b/crates/daemon/src/api/server/history.rs
@@ -9,14 +9,16 @@ use tracing::{Level, instrument};
use crate::{
aclient::{
+ database::{ClientSqlite, current_context},
history::{History, HistoryId, store::HistoryStore},
settings::Settings,
},
api::{
DAEMON_PROTOCOL_VERSION,
generated::history::{
- EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, StartHistoryReply,
- StartHistoryRequest, TailHistoryReply, TailHistoryRequest,
+ EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply,
+ HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply,
+ TailHistoryRequest,
history_server::{History as HistorySvc, HistoryServer},
},
},
@@ -32,14 +34,16 @@ pub(crate) struct HistoryService {
running: DashMap<HistoryId, History>,
/// Handle to the daemon (set during start).
- pub(crate) handle: DaemonHandle,
+ handle: DaemonHandle,
- /// History store for pushing records (set during start).
- pub(crate) history_store: HistoryStore,
+ /// History store for pushing records
+ history_store: HistoryStore,
+
+ history_db: ClientSqlite,
}
impl HistoryService {
- pub(crate) async fn new(handle: DaemonHandle) -> Result<Self> {
+ pub(crate) async fn new(handle: DaemonHandle, history_db: ClientSqlite) -> Result<Self> {
let host_id = Settings::host_id().await?;
let history_store =
HistoryStore::new(handle.store().clone(), host_id, *handle.encryption_key());
@@ -48,6 +52,7 @@ impl HistoryService {
running: DashMap::new(),
handle,
history_store,
+ history_db,
})
}
@@ -57,21 +62,18 @@ impl HistoryService {
}
}
-fn history_to_tail_reply(kind: HistoryEventKind, history: History) -> TailHistoryReply {
- TailHistoryReply {
- kind: kind as i32,
- history: Some(HistoryEntry {
- timestamp: history.timestamp.unix_timestamp_nanos() as u64,
- id: history.id.0,
- command: history.command,
- cwd: history.cwd,
- session: history.session,
- hostname: history.hostname,
- author: history.author,
- intent: history.intent.unwrap_or_default(),
- exit: history.exit,
- duration: history.duration,
- }),
+fn history_to_reply(history: History) -> HistoryEntry {
+ HistoryEntry {
+ timestamp: history.timestamp.unix_timestamp_nanos() as u64,
+ id: history.id.0,
+ command: history.command,
+ cwd: history.cwd,
+ session: history.session,
+ hostname: history.hostname,
+ author: history.author,
+ intent: history.intent.unwrap_or_default(),
+ exit: history.exit,
+ duration: history.duration,
}
}
@@ -80,6 +82,35 @@ impl HistorySvc for HistoryService {
type TailHistoryStream = Pin<Box<dyn Stream<Item = Result<TailHistoryReply, Status>> + Send>>;
#[instrument(skip_all, level = Level::INFO)]
+ async fn history(
+ &self,
+ request: Request<HistoryRequest>,
+ ) -> Result<Response<HistoryReply>, Status> {
+ let req = request.into_inner();
+
+ let context = current_context(req.session)
+ .await
+ .map_err(|e| Status::internal(format!("failed to aquire context: {e:?}")))?;
+
+ let entries = if let Some(range) = req.range {
+ let from = OffsetDateTime::from_unix_timestamp(range.start as i64).unwrap();
+ let to = OffsetDateTime::from_unix_timestamp(range.end as i64).unwrap();
+
+ self.history_db.range(from, to).await
+ } else {
+ self.history_db
+ .list(&[], &context, None, false, false)
+ .await
+ }
+ .map_err(|e| Status::internal(format!("failed to read db: {e:?}")))?
+ .into_iter()
+ .map(history_to_reply)
+ .collect();
+
+ Ok(Response::new(HistoryReply { entries }))
+ }
+
+ #[instrument(skip_all, level = Level::INFO)]
async fn start_history(
&self,
request: Request<StartHistoryRequest>,
@@ -120,7 +151,6 @@ impl HistorySvc for HistoryService {
}
#[instrument(skip_all, level = Level::INFO)]
- #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")]
async fn end_history(
&self,
request: Request<EndHistoryRequest>,
@@ -195,12 +225,14 @@ impl HistorySvc for HistoryService {
};
let reply = match event {
- DaemonEvent::HistoryStarted(history) => {
- Some(history_to_tail_reply(HistoryEventKind::Started, history))
- }
- DaemonEvent::HistoryEnded(history) => {
- Some(history_to_tail_reply(HistoryEventKind::Ended, history))
- }
+ DaemonEvent::HistoryStarted(history) => Some(TailHistoryReply {
+ kind: HistoryEventKind::Started.into(),
+ history: Some(history_to_reply(history)),
+ }),
+ DaemonEvent::HistoryEnded(history) => Some(TailHistoryReply {
+ kind: HistoryEventKind::Ended.into(),
+ history: Some(history_to_reply(history)),
+ }),
_ => None,
};
diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs
index 4f96e410..c6877096 100644
--- a/crates/daemon/src/lib.rs
+++ b/crates/daemon/src/lib.rs
@@ -12,10 +12,7 @@ use fs4::fs_std::FileExt;
use tokio::time::sleep;
use crate::{
- aclient::{
- database::ClientSqlite as HistoryDatabase, record::sqlite_store::SqliteStore,
- settings::Settings,
- },
+ aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings},
api::{
DAEMON_VERSION,
server::{control::ControlService, history::HistoryService},
@@ -34,17 +31,13 @@ pub(crate) mod server;
///
/// This creates a daemon,
/// starts the gRPC server with services, and runs the event loop.
-pub async fn boot(
- settings: Settings,
- store: SqliteStore,
- history_db: HistoryDatabase,
-) -> Result<()> {
+pub async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> {
let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?;
let mut daemon = Daemon::builder(settings.clone())
.store(store)
- .history_db(history_db)
+ .history_db(history_db.clone())
.build()?;
let handle = {
@@ -61,7 +54,7 @@ pub async fn boot(
handle
};
- let history_service = HistoryService::new(handle.clone()).await?;
+ let history_service = HistoryService::new(handle.clone(), history_db).await?;
let control_service = ControlService::new(handle.clone());
server::run_grpc_server(