aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-04-11 01:32:24 +0100
committerGitHub <noreply@github.com>2026-04-11 01:32:24 +0100
commit7e47f4df6ceb0fe7e32c166776e4e3b960039b67 (patch)
tree5e512c17c6c9d36c6e347a579a9baacc56832817
parentchore: Prepare 18.14.0-beta.1 release (#3393) (diff)
downloadatuin-7e47f4df6ceb0fe7e32c166776e4e3b960039b67.zip
feat: add history tail for live monitoring view (#3389)
Useful for watching what agents are doing, or viewing live info from other machines. Regardless I am liking it for debugging ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing
-rw-r--r--crates/atuin-daemon/proto/history.proto27
-rw-r--r--crates/atuin-daemon/src/client.rs11
-rw-r--r--crates/atuin-daemon/src/components/history.rs81
-rw-r--r--crates/atuin-daemon/tests/lifecycle.rs50
-rw-r--r--crates/atuin/src/command/client/daemon.rs17
-rw-r--r--crates/atuin/src/command/client/history.rs407
6 files changed, 587 insertions, 6 deletions
diff --git a/crates/atuin-daemon/proto/history.proto b/crates/atuin-daemon/proto/history.proto
index 2a45b7cf..59c12471 100644
--- a/crates/atuin-daemon/proto/history.proto
+++ b/crates/atuin-daemon/proto/history.proto
@@ -46,9 +46,36 @@ message ShutdownReply {
bool accepted = 1;
}
+message TailHistoryRequest {}
+
+enum HistoryEventKind {
+ HISTORY_EVENT_KIND_UNSPECIFIED = 0;
+ HISTORY_EVENT_KIND_STARTED = 1;
+ HISTORY_EVENT_KIND_ENDED = 2;
+}
+
+message HistoryEntry {
+ uint64 timestamp = 1; // nanosecond unix epoch
+ string id = 2;
+ string command = 3;
+ string cwd = 4;
+ string session = 5;
+ string hostname = 6;
+ string author = 7;
+ string intent = 8;
+ int64 exit = 9;
+ int64 duration = 10;
+}
+
+message TailHistoryReply {
+ HistoryEventKind kind = 1;
+ HistoryEntry history = 2;
+}
+
service History {
rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply);
rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply);
+ rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply);
rpc Status(StatusRequest) returns (StatusReply);
rpc Shutdown(ShutdownRequest) returns (ShutdownReply);
}
diff --git a/crates/atuin-daemon/src/client.rs b/crates/atuin-daemon/src/client.rs
index 2f492f6b..5f4ce20f 100644
--- a/crates/atuin-daemon/src/client.rs
+++ b/crates/atuin-daemon/src/client.rs
@@ -23,7 +23,8 @@ use crate::control::{
use crate::events::DaemonEvent;
use crate::history::{
EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, StartHistoryRequest,
- StatusReply, StatusRequest, history_client::HistoryClient as HistoryServiceClient,
+ StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest,
+ history_client::HistoryClient as HistoryServiceClient,
};
use crate::search::{
FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, SearchResponse,
@@ -140,6 +141,14 @@ impl HistoryClient {
Ok(self.client.status(StatusRequest {}).await?.into_inner())
}
+ pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
+ Ok(self
+ .client
+ .tail_history(TailHistoryRequest {})
+ .await?
+ .into_inner())
+ }
+
pub async fn shutdown(&mut self) -> Result<bool> {
let resp = self.client.shutdown(ShutdownRequest {}).await?.into_inner();
Ok(resp.accepted)
diff --git a/crates/atuin-daemon/src/components/history.rs b/crates/atuin-daemon/src/components/history.rs
index 23d48c5e..c82c8f94 100644
--- a/crates/atuin-daemon/src/components/history.rs
+++ b/crates/atuin-daemon/src/components/history.rs
@@ -2,7 +2,7 @@
//!
//! Handles command history lifecycle (start/end) and provides the History gRPC service.
-use std::sync::Arc;
+use std::{pin::Pin, sync::Arc};
use atuin_client::{
database::Database,
@@ -12,6 +12,7 @@ use atuin_client::{
use dashmap::DashMap;
use eyre::Result;
use time::OffsetDateTime;
+use tokio_stream::Stream;
use tonic::{Request, Response, Status};
use tracing::{Level, instrument};
@@ -19,8 +20,9 @@ use crate::{
daemon::{Component, DaemonHandle},
events::DaemonEvent,
history::{
- EndHistoryReply, EndHistoryRequest, ShutdownReply, ShutdownRequest, StartHistoryReply,
- StartHistoryRequest, StatusReply, StatusRequest,
+ EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, ShutdownReply,
+ ShutdownRequest, StartHistoryReply, StartHistoryRequest, StatusReply, StatusRequest,
+ TailHistoryReply, TailHistoryRequest,
history_server::{History as HistorySvc, HistoryServer},
},
};
@@ -114,8 +116,28 @@ pub struct HistoryGrpcService {
inner: Arc<HistoryComponentInner>,
}
+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,
+ }),
+ }
+}
+
#[tonic::async_trait]
impl HistorySvc for HistoryGrpcService {
+ type TailHistoryStream = Pin<Box<dyn Stream<Item = Result<TailHistoryReply, Status>> + Send>>;
+
#[instrument(skip_all, level = Level::INFO)]
async fn start_history(
&self,
@@ -136,6 +158,8 @@ impl HistorySvc for HistoryGrpcService {
.cwd(req.cwd)
.session(req.session)
.hostname(req.hostname)
+ .author(req.author)
+ .intent(req.intent)
.build()
.into();
@@ -224,6 +248,57 @@ impl HistorySvc for HistoryGrpcService {
}
#[instrument(skip_all, level = Level::INFO)]
+ async fn tail_history(
+ &self,
+ _request: Request<TailHistoryRequest>,
+ ) -> Result<Response<Self::TailHistoryStream>, Status> {
+ let handle_guard = self.inner.handle.read().await;
+ let handle = handle_guard
+ .as_ref()
+ .cloned()
+ .ok_or_else(|| Status::internal("component not initialized"))?;
+
+ let mut rx = handle.subscribe();
+ let (tx, out_rx) = tokio::sync::mpsc::channel::<Result<TailHistoryReply, Status>>(128);
+
+ tokio::spawn(async move {
+ loop {
+ let event = match rx.recv().await {
+ Ok(event) => event,
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
+ let _ = tx
+ .send(Err(Status::resource_exhausted(format!(
+ "tail stream lagged behind and dropped {skipped} events"
+ ))))
+ .await;
+ break;
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
+ };
+
+ 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))
+ }
+ _ => None,
+ };
+
+ if let Some(reply) = reply
+ && tx.send(Ok(reply)).await.is_err()
+ {
+ break;
+ }
+ }
+ });
+
+ let stream = tokio_stream::wrappers::ReceiverStream::new(out_rx);
+ Ok(Response::new(Box::pin(stream)))
+ }
+
+ #[instrument(skip_all, level = Level::INFO)]
async fn status(
&self,
_request: Request<StatusRequest>,
diff --git a/crates/atuin-daemon/tests/lifecycle.rs b/crates/atuin-daemon/tests/lifecycle.rs
index 3b6952de..4a91e5cb 100644
--- a/crates/atuin-daemon/tests/lifecycle.rs
+++ b/crates/atuin-daemon/tests/lifecycle.rs
@@ -146,6 +146,56 @@ mod unix {
}
#[tokio::test]
+ async fn test_tail_history_streams_started_and_ended_events() {
+ use atuin_client::history::History;
+ use atuin_daemon::history::HistoryEventKind;
+
+ let (mut client, _handle, _tmp) = start_test_daemon().await;
+ let mut stream = client.tail_history().await.unwrap();
+
+ let history = History::daemon()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("git status".to_string())
+ .cwd("/tmp/repo".to_string())
+ .session("tail-session".to_string())
+ .hostname("test-host:ellie".to_string())
+ .author("claude".to_string())
+ .intent("inspect repository state".to_string())
+ .build()
+ .into();
+
+ let start_reply = client.start_history(history).await.unwrap();
+
+ let started = stream.message().await.unwrap().unwrap();
+ assert_eq!(
+ HistoryEventKind::try_from(started.kind).unwrap(),
+ HistoryEventKind::Started
+ );
+ let started_history = started.history.unwrap();
+ assert_eq!(started_history.id, start_reply.id);
+ assert_eq!(started_history.command, "git status");
+ assert_eq!(started_history.cwd, "/tmp/repo");
+ assert_eq!(started_history.hostname, "test-host:ellie");
+ assert_eq!(started_history.author, "claude");
+ assert_eq!(started_history.intent, "inspect repository state");
+
+ client
+ .end_history(start_reply.id.clone(), 1_000_000, 0)
+ .await
+ .unwrap();
+
+ let ended = stream.message().await.unwrap().unwrap();
+ assert_eq!(
+ HistoryEventKind::try_from(ended.kind).unwrap(),
+ HistoryEventKind::Ended
+ );
+ let ended_history = ended.history.unwrap();
+ assert_eq!(ended_history.id, start_reply.id);
+ assert_eq!(ended_history.exit, 0);
+ assert_eq!(ended_history.duration, 1_000_000);
+ }
+
+ #[tokio::test]
async fn test_end_unknown_history_fails() {
let (mut client, _handle, _tmp) = start_test_daemon().await;
diff --git a/crates/atuin/src/command/client/daemon.rs b/crates/atuin/src/command/client/daemon.rs
index 64547505..7847ea2d 100644
--- a/crates/atuin/src/command/client/daemon.rs
+++ b/crates/atuin/src/command/client/daemon.rs
@@ -465,6 +465,23 @@ pub async fn end_history(settings: &Settings, id: String, duration: u64, exit: i
Ok(())
}
+pub async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
+ match probe(settings).await {
+ Probe::Ready(client) => return Ok(client),
+ Probe::NeedsRestart(reason) if !settings.daemon.autostart => {
+ bail!("{reason}. Enable `daemon.autostart = true` or restart the daemon manually");
+ }
+ Probe::Unreachable(err) if is_legacy_daemon_error(&err) => {
+ return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE));
+ }
+ Probe::Unreachable(err) if !settings.daemon.autostart => return Err(err),
+ Probe::Unreachable(err) if !should_retry_after_error(&err) => return Err(err),
+ Probe::NeedsRestart(_) | Probe::Unreachable(_) => {}
+ }
+
+ restart_daemon(settings).await
+}
+
async fn status_cmd(settings: &Settings) -> Result<()> {
match probe(settings).await {
Probe::Ready(mut client) => {
diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs
index 4d81d144..39e2c9f6 100644
--- a/crates/atuin/src/command/client/history.rs
+++ b/crates/atuin/src/command/client/history.rs
@@ -7,11 +7,19 @@ use std::{
use atuin_common::utils::{self, Escapable as _};
use clap::Subcommand;
-use eyre::{Context, Result};
+use eyre::{Context, Result, bail};
use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
#[cfg(feature = "daemon")]
-use atuin_daemon::emit_event;
+use colored::Colorize;
+#[cfg(feature = "daemon")]
+use serde::Serialize;
+
+#[cfg(feature = "daemon")]
+use atuin_daemon::{
+ emit_event,
+ history::{HistoryEventKind, TailHistoryReply},
+};
use atuin_client::{
database::{Database, Sqlite, current_context},
@@ -64,6 +72,9 @@ pub enum Cmd {
duration: Option<u64>,
},
+ /// Stream history events from the daemon as they are received
+ Tail,
+
/// List all items in history
List {
#[arg(long, short)]
@@ -364,6 +375,307 @@ fn parse_fmt(format: &str) -> ParsedFmt<'_> {
}
}
+#[cfg(feature = "daemon")]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum TailKind {
+ Started,
+ Ended,
+}
+
+#[cfg(feature = "daemon")]
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct TailEvent {
+ kind: TailKind,
+ history: History,
+}
+
+#[cfg(feature = "daemon")]
+#[derive(Serialize)]
+struct TailJsonEvent<'a> {
+ event: &'static str,
+ history: TailJsonHistory<'a>,
+}
+
+#[cfg(feature = "daemon")]
+#[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>,
+}
+
+#[cfg(feature = "daemon")]
+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(),
+ }
+ }
+}
+
+#[cfg(feature = "daemon")]
+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(),
+ }
+ }
+}
+
+#[cfg(feature = "daemon")]
+fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
+ Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?)
+}
+
+#[cfg(feature = "daemon")]
+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()
+}
+
+#[cfg(feature = "daemon")]
+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');
+ }
+}
+
+#[cfg(feature = "daemon")]
+fn normalize_optional_field(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
impl Cmd {
fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str {
if !settings.strip_trailing_whitespace {
@@ -559,6 +871,28 @@ impl Cmd {
Ok(())
}
+ #[cfg(feature = "daemon")]
+ async fn handle_tail(settings: &Settings) -> Result<()> {
+ let tty = std::io::stdout().is_terminal();
+ let mut client = daemon::tail_client(settings).await?;
+ let mut stream = client.tail_history().await?;
+ let stdout = std::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(())
+ }
+
#[allow(clippy::too_many_arguments)]
#[allow(clippy::fn_params_excessive_bools)]
async fn handle_list(
@@ -721,6 +1055,7 @@ impl Cmd {
Ok(())
}
+ #[allow(clippy::too_many_lines)]
pub async fn run(self, settings: &Settings) -> Result<()> {
let context = current_context().await?;
@@ -738,10 +1073,22 @@ impl Cmd {
return Self::handle_daemon_end(settings, &id, exit, duration).await;
}
+ Self::Tail => {
+ return Self::handle_tail(settings).await;
+ }
+
_ => {}
}
}
+ if matches!(self, Self::Tail) {
+ #[cfg(feature = "daemon")]
+ bail!("`atuin history tail` requires `daemon.enabled = true`");
+
+ #[cfg(not(feature = "daemon"))]
+ bail!("`atuin history tail` requires Atuin to be built with the `daemon` feature");
+ }
+
let db_path = PathBuf::from(settings.db_path.as_str());
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
@@ -764,6 +1111,7 @@ impl Cmd {
Self::End { id, exit, duration } => {
Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await
}
+ Self::Tail => unreachable!("tail handled before database initialization"),
Self::List {
session,
cwd,
@@ -854,6 +1202,9 @@ impl Cmd {
#[cfg(test)]
mod tests {
+ #[cfg(feature = "daemon")]
+ use time::macros::datetime;
+
use super::*;
#[test]
@@ -935,4 +1286,56 @@ mod tests {
assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok());
assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok());
}
+
+ #[cfg(feature = "daemon")]
+ 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,
+ },
+ }
+ }
+
+ #[cfg(feature = "daemon")]
+ #[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());
+ }
+
+ #[cfg(feature = "daemon")]
+ #[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"));
+ }
}