aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai')
-rw-r--r--crates/atuin-ai/Cargo.toml2
-rw-r--r--crates/atuin-ai/src/commands/inline.rs3
-rw-r--r--crates/atuin-ai/src/context.rs27
-rw-r--r--crates/atuin-ai/src/driver.rs16
-rw-r--r--crates/atuin-ai/src/history_format.rs120
-rw-r--r--crates/atuin-ai/src/lib.rs1
-rw-r--r--crates/atuin-ai/src/permissions/check.rs6
-rw-r--r--crates/atuin-ai/src/stream.rs13
-rw-r--r--crates/atuin-ai/src/tools/descriptor.rs10
-rw-r--r--crates/atuin-ai/src/tools/mod.rs281
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs1
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs1
12 files changed, 396 insertions, 85 deletions
diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml
index 06e50a4e..027bd490 100644
--- a/crates/atuin-ai/Cargo.toml
+++ b/crates/atuin-ai/Cargo.toml
@@ -14,12 +14,14 @@ repository = { workspace = true }
[features]
default = []
+daemon = []
tree-sitter = ["dep:tree-sitter-lib", "dep:tree-sitter-bash", "dep:tree-sitter-fish"]
[dependencies]
async-trait = { workspace = true }
atuin-client = { workspace = true }
atuin-common = { workspace = true }
+atuin-daemon = { workspace = true }
tokio = { workspace = true }
eyre = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index 989b95c0..6d1f9c51 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -67,7 +67,7 @@ pub(crate) async fn run(
settings.ai.opening.send_cwd.unwrap_or(false) || settings.ai.send_cwd.unwrap_or(false);
let last_command = if settings.ai.opening.send_last_command.unwrap_or(false) {
- history_db.last().await.ok().flatten().map(|h| h.command)
+ history_db.last().await.ok().flatten()
} else {
None
};
@@ -84,6 +84,7 @@ pub(crate) async fn run(
history_db: std::sync::Arc::new(history_db),
git_root,
capabilities: settings.ai.capabilities.clone(),
+ daemon_enabled: settings.daemon.enabled,
};
let action = run_inline_tui(ctx, initial_command, settings).await?;
diff --git a/crates/atuin-ai/src/context.rs b/crates/atuin-ai/src/context.rs
index 625de0c6..f891a9fc 100644
--- a/crates/atuin-ai/src/context.rs
+++ b/crates/atuin-ai/src/context.rs
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use atuin_client::distro::detect_linux_distribution;
+use atuin_client::history::History;
use atuin_client::settings::AiCapabilities;
/// Session-scoped context for the AI chat session.
@@ -11,12 +12,17 @@ pub(crate) struct AppContext {
pub endpoint: String,
pub token: String,
pub send_cwd: bool,
- pub last_command: Option<String>,
+ pub last_command: Option<History>,
pub history_db: Arc<atuin_client::database::Sqlite>,
/// Git root of the current working directory, if inside a git repo.
/// Resolves through worktrees to the main repo root.
pub git_root: Option<PathBuf>,
pub capabilities: AiCapabilities,
+ pub daemon_enabled: bool,
+}
+
+pub(crate) fn history_output_capability_available(daemon_enabled: bool) -> bool {
+ cfg!(feature = "daemon") && daemon_enabled
}
impl AppContext {
@@ -33,6 +39,11 @@ impl AppContext {
if self.capabilities.enable_command_execution.unwrap_or(true) {
caps.push("client_v1_execute_shell_command".to_string());
}
+ if history_output_capability_available(self.daemon_enabled)
+ && self.capabilities.enable_history_output.unwrap_or(true)
+ {
+ caps.push("client_v1_atuin_output".to_string());
+ }
caps.push("client_v1_load_skill".to_string());
if let Ok(extra) = std::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
caps.extend(
@@ -69,7 +80,11 @@ impl ClientContext {
/// Serialize to the JSON format the API expects for the "context" field.
/// The `pwd` field is always dynamic (current working directory), so it's
/// computed fresh on each call if `send_cwd` is true.
- pub(crate) fn to_json(&self, send_cwd: bool, last_command: Option<&str>) -> serde_json::Value {
+ pub(crate) fn to_json(
+ &self,
+ send_cwd: bool,
+ last_command: Option<&History>,
+ ) -> serde_json::Value {
let mut ctx = serde_json::json!({
"os": self.os,
"shell": self.shell,
@@ -78,9 +93,15 @@ impl ClientContext {
} else {
None
},
- "last_command": last_command,
});
+ if let Some(history) = last_command {
+ ctx["last_command"] = serde_json::json!(crate::history_format::format_last_command(
+ history,
+ crate::history_format::current_local_offset(),
+ ));
+ }
+
if let Some(ref distro) = self.distro {
ctx["distro"] = serde_json::json!(distro);
}
diff --git a/crates/atuin-ai/src/driver.rs b/crates/atuin-ai/src/driver.rs
index ddb839b7..82d666ef 100644
--- a/crates/atuin-ai/src/driver.rs
+++ b/crates/atuin-ai/src/driver.rs
@@ -492,6 +492,7 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
messages.clone(),
session_id.clone(),
&app.capabilities,
+ app.daemon_enabled,
fsm.ctx.invocation_id.clone(),
);
tokio::spawn(async move {
@@ -570,7 +571,6 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
Effect::ExecuteTool { tool_id, tool } => {
let tool_id = tool_id.clone();
- let tool = tool.clone();
let tx = tx.clone();
let db = io.app_ctx.history_db.clone();
@@ -731,8 +731,9 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
preview: None,
}));
}
- ClientToolCall::AtuinHistory(_) => {
+ ClientToolCall::AtuinHistory(tool) => {
// History search needs async DB access
+ let tool = tool.clone();
tokio::spawn(async move {
let outcome = tool.execute(&db).await;
let _ = tx.send(DriverEvent::Fsm(Event::ToolExecutionDone {
@@ -742,6 +743,17 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
}));
});
}
+ ClientToolCall::AtuinOutput(tool) => {
+ let tool = tool.clone();
+ tokio::spawn(async move {
+ let outcome = tool.execute().await;
+ let _ = tx.send(DriverEvent::Fsm(Event::ToolExecutionDone {
+ tool_id,
+ outcome,
+ preview: None,
+ }));
+ });
+ }
ClientToolCall::LoadSkill(skill_call) => {
let skill_name = skill_call.name.clone();
let registry = io.skill_registry.clone();
diff --git a/crates/atuin-ai/src/history_format.rs b/crates/atuin-ai/src/history_format.rs
new file mode 100644
index 00000000..24aa963e
--- /dev/null
+++ b/crates/atuin-ai/src/history_format.rs
@@ -0,0 +1,120 @@
+use atuin_client::history::History;
+use time::UtcOffset;
+
+pub(crate) fn current_local_offset() -> UtcOffset {
+ UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)
+}
+
+pub(crate) fn format_last_command(history: &History, local_offset: UtcOffset) -> String {
+ format!(
+ "History ID: {} - `{}`\n{}",
+ history.id,
+ history.command,
+ format_history_metadata(history, local_offset)
+ )
+}
+
+pub(crate) fn format_history_search_result(
+ ordinal: usize,
+ history: &History,
+ local_offset: UtcOffset,
+) -> String {
+ format!(
+ "## #{}. (History ID: {}):\n`{}`\n{}\n",
+ ordinal,
+ history.id,
+ history.command,
+ format_history_metadata(history, local_offset)
+ )
+}
+
+fn format_history_metadata(history: &History, local_offset: UtcOffset) -> String {
+ format!(
+ "[{}] (in `{}`, exit {}){}",
+ format_timestamp(history, local_offset),
+ history.cwd,
+ history.exit,
+ format_duration(history.duration)
+ )
+}
+
+fn format_timestamp(history: &History, local_offset: UtcOffset) -> String {
+ let ts = history.timestamp.to_offset(local_offset);
+ format!(
+ "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
+ ts.year(),
+ ts.month() as u8,
+ ts.day(),
+ ts.hour(),
+ ts.minute(),
+ ts.second(),
+ )
+}
+
+fn format_duration(nanos: i64) -> String {
+ if nanos <= 0 {
+ return String::new();
+ }
+
+ let total_secs = nanos / 1_000_000_000;
+ let millis = (nanos % 1_000_000_000) / 1_000_000;
+
+ if total_secs >= 3600 {
+ let hours = total_secs / 3600;
+ let mins = (total_secs % 3600) / 60;
+ let secs = total_secs % 60;
+ format!(", {hours}h{mins}m{secs}s")
+ } else if total_secs >= 60 {
+ let mins = total_secs / 60;
+ let secs = total_secs % 60;
+ format!(", {mins}m{secs}s")
+ } else if total_secs > 0 {
+ if millis > 0 {
+ format!(", {total_secs}.{millis:03}s")
+ } else {
+ format!(", {total_secs}s")
+ }
+ } else {
+ format!(", {millis}ms")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use atuin_client::history::{History, HistoryId};
+ use time::{OffsetDateTime, UtcOffset};
+
+ use super::*;
+
+ fn history(duration: i64) -> History {
+ History {
+ id: HistoryId("018f011c-9a0a-7000-8000-000000000001".to_string()),
+ timestamp: OffsetDateTime::UNIX_EPOCH,
+ duration,
+ exit: 2,
+ command: "cargo test".to_string(),
+ cwd: "/repo".to_string(),
+ session: String::new(),
+ hostname: String::new(),
+ author: String::new(),
+ intent: None,
+ deleted_at: None,
+ }
+ }
+
+ #[test]
+ fn formats_last_command() {
+ assert_eq!(
+ format_last_command(&history(1_234_000_000), UtcOffset::UTC),
+ "History ID: 018f011c-9a0a-7000-8000-000000000001 - `cargo test`\n[1970-01-01 00:00:00] (in `/repo`, exit 2), 1.234s"
+ );
+ }
+
+ #[test]
+ fn formats_history_search_result() {
+ assert_eq!(
+ format_history_search_result(3, &history(0), UtcOffset::UTC),
+ "## #3. (History ID: 018f011c-9a0a-7000-8000-000000000001):\n`cargo test`\n[1970-01-01 00:00:00] (in `/repo`, exit 2)\n"
+ );
+ }
+}
diff --git a/crates/atuin-ai/src/lib.rs b/crates/atuin-ai/src/lib.rs
index b3587739..f972d4ff 100644
--- a/crates/atuin-ai/src/lib.rs
+++ b/crates/atuin-ai/src/lib.rs
@@ -7,6 +7,7 @@ pub(crate) mod edit_permissions;
pub(crate) mod event_serde;
pub(crate) mod file_tracker;
pub(crate) mod fsm;
+pub(crate) mod history_format;
pub(crate) mod permissions;
pub(crate) mod session;
pub(crate) mod skills;
diff --git a/crates/atuin-ai/src/permissions/check.rs b/crates/atuin-ai/src/permissions/check.rs
index 96abc3ab..bb1eae0c 100644
--- a/crates/atuin-ai/src/permissions/check.rs
+++ b/crates/atuin-ai/src/permissions/check.rs
@@ -1,13 +1,13 @@
use eyre::Result;
-use crate::{permissions::file::RuleFile, tools::PermissableToolCall};
+use crate::{permissions::file::RuleFile, tools::PermissibleToolCall};
pub(crate) struct PermissionRequest<'t> {
- call: &'t (dyn PermissableToolCall + Send + Sync),
+ call: &'t (dyn PermissibleToolCall + Send + Sync),
}
impl<'t> PermissionRequest<'t> {
- pub fn new(call: &'t (dyn PermissableToolCall + Send + Sync)) -> Self {
+ pub fn new(call: &'t (dyn PermissibleToolCall + Send + Sync)) -> Self {
Self { call }
}
}
diff --git a/crates/atuin-ai/src/stream.rs b/crates/atuin-ai/src/stream.rs
index 084e8238..e78dc2e1 100644
--- a/crates/atuin-ai/src/stream.rs
+++ b/crates/atuin-ai/src/stream.rs
@@ -2,7 +2,10 @@
// SSE streaming
// ───────────────────────────────────────────────────────────────────
+use atuin_client::history::History;
use atuin_client::settings::AiCapabilities;
+
+use crate::context::history_output_capability_available;
use atuin_common::tls::ensure_crypto_provider;
use eventsource_stream::Eventsource;
@@ -61,6 +64,7 @@ impl ChatRequest {
messages: Vec<serde_json::Value>,
session_id: Option<String>,
capabilities: &AiCapabilities,
+ history_output_available: bool,
invocation_id: String,
) -> Self {
let mut caps = vec![
@@ -78,6 +82,11 @@ impl ChatRequest {
if capabilities.enable_command_execution.unwrap_or(true) {
caps.push("client_v1_execute_shell_command".to_string());
}
+ if history_output_capability_available(history_output_available)
+ && capabilities.enable_history_output.unwrap_or(true)
+ {
+ caps.push("client_v1_atuin_output".to_string());
+ }
if let Ok(extra) = std::env::var("ATUIN_AI__ADDITIONAL_CAPS") {
caps.extend(
extra
@@ -103,7 +112,7 @@ pub(crate) fn create_chat_stream(
request: ChatRequest,
client_ctx: ClientContext,
send_cwd: bool,
- last_command: Option<String>,
+ last_command: Option<History>,
user_contexts: Vec<crate::user_context::UserContext>,
skill_summaries: Vec<crate::skills::SkillSummary>,
skill_overflow: Option<String>,
@@ -120,7 +129,7 @@ pub(crate) fn create_chat_stream(
tracing::debug!("Sending SSE request to {endpoint}");
- let context = client_ctx.to_json(send_cwd, last_command.as_deref());
+ let context = client_ctx.to_json(send_cwd, last_command.as_ref());
let mut config = serde_json::json!({
"capabilities": request.capabilities,
diff --git a/crates/atuin-ai/src/tools/descriptor.rs b/crates/atuin-ai/src/tools/descriptor.rs
index 06858bf8..4190540c 100644
--- a/crates/atuin-ai/src/tools/descriptor.rs
+++ b/crates/atuin-ai/src/tools/descriptor.rs
@@ -67,6 +67,15 @@ pub(crate) const ATUIN_HISTORY: &ToolDescriptor = &ToolDescriptor {
is_client: true,
};
+pub(crate) const ATUIN_OUTPUT: &ToolDescriptor = &ToolDescriptor {
+ canonical_names: &["atuin_output"],
+ capability: Some("client_v1_atuin_output"),
+ display_verb: "view the output for command",
+ progressive_verb: "Viewing output...",
+ past_verb: "Viewed output",
+ is_client: true,
+};
+
pub(crate) const LOAD_SKILL: &ToolDescriptor = &ToolDescriptor {
canonical_names: &["load_skill"],
capability: Some("client_v1_load_skill"),
@@ -104,6 +113,7 @@ const ALL_DESCRIPTORS: &[&ToolDescriptor] = &[
WRITE,
SHELL,
ATUIN_HISTORY,
+ ATUIN_OUTPUT,
LOAD_SKILL,
SERVER_SEARCH,
SERVER_SCRAPE,
diff --git a/crates/atuin-ai/src/tools/mod.rs b/crates/atuin-ai/src/tools/mod.rs
index fdda10a4..d1352661 100644
--- a/crates/atuin-ai/src/tools/mod.rs
+++ b/crates/atuin-ai/src/tools/mod.rs
@@ -5,6 +5,7 @@ use std::{
};
use eyre::Result;
+use uuid::Uuid;
const DEFAULT_FILE_READ_LINES: u64 = 100;
const MAX_FILE_READ_LINES: u64 = 1000;
@@ -158,6 +159,7 @@ pub(crate) enum ClientToolCall {
Write(WriteToolCall),
Shell(ShellToolCall),
AtuinHistory(AtuinHistoryToolCall),
+ AtuinOutput(AtuinOutputToolCall),
LoadSkill(LoadSkillToolCall),
}
@@ -173,6 +175,9 @@ impl TryFrom<(&str, &serde_json::Value)> for ClientToolCall {
"atuin_history" => Ok(ClientToolCall::AtuinHistory(
AtuinHistoryToolCall::try_from(input)?,
)),
+ "atuin_output" => Ok(ClientToolCall::AtuinOutput(AtuinOutputToolCall::try_from(
+ input,
+ )?)),
"load_skill" => Ok(ClientToolCall::LoadSkill(LoadSkillToolCall::try_from(
input,
)?)),
@@ -189,6 +194,7 @@ impl ClientToolCall {
ClientToolCall::Write(_) => descriptor::WRITE,
ClientToolCall::Shell(_) => descriptor::SHELL,
ClientToolCall::AtuinHistory(_) => descriptor::ATUIN_HISTORY,
+ ClientToolCall::AtuinOutput(_) => descriptor::ATUIN_OUTPUT,
ClientToolCall::LoadSkill(_) => descriptor::LOAD_SKILL,
}
}
@@ -205,6 +211,7 @@ impl ClientToolCall {
ClientToolCall::Write(_) => "Write",
ClientToolCall::Shell(_) => "Shell",
ClientToolCall::AtuinHistory(_) => "AtuinHistory",
+ ClientToolCall::AtuinOutput(_) => "AtuinOutput",
ClientToolCall::LoadSkill(_) => "LoadSkill",
}
}
@@ -218,6 +225,7 @@ impl ClientToolCall {
ClientToolCall::Write(tool) => Some(tool.resolved_path()),
ClientToolCall::Shell(_)
| ClientToolCall::AtuinHistory(_)
+ | ClientToolCall::AtuinOutput(_)
| ClientToolCall::LoadSkill(_) => None,
}
}
@@ -229,6 +237,7 @@ impl ClientToolCall {
ClientToolCall::Write(tool) => tool.matches_rule(rule),
ClientToolCall::Shell(tool) => tool.matches_rule(rule),
ClientToolCall::AtuinHistory(tool) => tool.matches_rule(rule),
+ ClientToolCall::AtuinOutput(tool) => tool.matches_rule(rule),
ClientToolCall::LoadSkill(tool) => tool.matches_rule(rule),
}
}
@@ -240,26 +249,14 @@ impl ClientToolCall {
ClientToolCall::Write(tool) => tool.target_dir(),
ClientToolCall::Shell(tool) => tool.target_dir(),
ClientToolCall::AtuinHistory(tool) => tool.target_dir(),
+ ClientToolCall::AtuinOutput(tool) => tool.target_dir(),
ClientToolCall::LoadSkill(tool) => tool.target_dir(),
}
}
-
- /// Execute this client-side tool and return the result.
- pub async fn execute(&self, db: &atuin_client::database::Sqlite) -> ToolOutcome {
- match self {
- ClientToolCall::Read(tool) => tool.execute(),
- ClientToolCall::AtuinHistory(tool) => tool.execute(db).await,
- // LoadSkill is handled separately by the driver (needs registry access)
- ClientToolCall::LoadSkill(_) => {
- ToolOutcome::Error("LoadSkill must be executed via the driver".to_string())
- }
- _ => ToolOutcome::Error("Client-side tool execution not yet implemented".to_string()),
- }
- }
}
/// A trait for tool calls that can be checked against permission rules.
-pub(crate) trait PermissableToolCall {
+pub(crate) trait PermissibleToolCall {
/// Checks if this tool call matches the given permission rule.
fn matches_rule(&self, rule: &Rule) -> bool;
@@ -277,7 +274,7 @@ pub(crate) trait PermissableToolCall {
}
}
-impl PermissableToolCall for ClientToolCall {
+impl PermissibleToolCall for ClientToolCall {
fn matches_rule(&self, rule: &Rule) -> bool {
self.matches_rule(rule)
}
@@ -416,7 +413,7 @@ impl ReadToolCall {
}
}
-impl PermissableToolCall for ReadToolCall {
+impl PermissibleToolCall for ReadToolCall {
fn target_dir(&self) -> Option<&Path> {
Some(&self.path)
}
@@ -616,7 +613,7 @@ impl EditToolCall {
}
}
-impl PermissableToolCall for EditToolCall {
+impl PermissibleToolCall for EditToolCall {
fn target_dir(&self) -> Option<&Path> {
Some(&self.path)
}
@@ -724,7 +721,7 @@ impl WriteToolCall {
}
}
-impl PermissableToolCall for WriteToolCall {
+impl PermissibleToolCall for WriteToolCall {
fn target_dir(&self) -> Option<&Path> {
Some(&self.path)
}
@@ -792,7 +789,7 @@ impl TryFrom<&serde_json::Value> for ShellToolCall {
}
}
-impl PermissableToolCall for ShellToolCall {
+impl PermissibleToolCall for ShellToolCall {
fn target_dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
@@ -1134,7 +1131,7 @@ impl TryFrom<&serde_json::Value> for AtuinHistoryToolCall {
}
}
-impl PermissableToolCall for AtuinHistoryToolCall {
+impl PermissibleToolCall for AtuinHistoryToolCall {
fn target_dir(&self) -> Option<&Path> {
None
}
@@ -1148,7 +1145,6 @@ impl AtuinHistoryToolCall {
pub(crate) async fn execute(&self, db: &atuin_client::database::Sqlite) -> ToolOutcome {
use atuin_client::database::{self, Database as _, OptFilters};
use atuin_client::settings::SearchMode;
- use time::UtcOffset;
let context = match database::current_context().await {
Ok(ctx) => ctx,
@@ -1184,34 +1180,13 @@ impl AtuinHistoryToolCall {
return ToolOutcome::Success("No matching history entries found.".to_string());
}
- let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+ let local_offset = crate::history_format::current_local_offset();
let formatted: Vec<String> = results
.iter()
.enumerate()
- .map(|(i, h)| {
- let ts = h.timestamp.to_offset(local_offset);
- let time_str = format!(
- "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
- ts.year(),
- ts.month() as u8,
- ts.day(),
- ts.hour(),
- ts.minute(),
- ts.second(),
- );
-
- let duration_str = format_duration(h.duration);
-
- format!(
- "{}. `{}` [{}] ({}, exit: {}){}",
- i + 1,
- h.command,
- time_str,
- h.cwd,
- h.exit,
- duration_str,
- )
+ .map(|(i, history)| {
+ crate::history_format::format_history_search_result(i + 1, history, local_offset)
})
.collect();
@@ -1220,6 +1195,146 @@ impl AtuinHistoryToolCall {
}
#[derive(Debug, Clone)]
+pub(crate) struct AtuinOutputToolCall {
+ pub history_id: Uuid,
+ pub ranges: Vec<(i64, i64)>,
+}
+
+impl TryFrom<&serde_json::Value> for AtuinOutputToolCall {
+ type Error = eyre::Error;
+
+ fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
+ let history_id = value
+ .get("history_id")
+ .and_then(|v| v.as_str())
+ .and_then(|v| Uuid::parse_str(v).ok())
+ .ok_or(eyre::eyre!("Missing or invalid history ID"))?;
+
+ let ranges = value
+ .get("ranges")
+ .and_then(|v| v.as_array())
+ .map(Vec::as_slice)
+ .unwrap_or(&[]);
+
+ let ranges = ranges
+ .iter()
+ .map(|r| {
+ let range = r
+ .as_array()
+ .filter(|a| a.len() == 2)
+ .ok_or_else(|| eyre::eyre!("Each range must be a [start, end] array"))?;
+
+ let start = range[0]
+ .as_i64()
+ .ok_or_else(|| eyre::eyre!("Range start must be an integer"))?;
+ let end = range[1]
+ .as_i64()
+ .ok_or_else(|| eyre::eyre!("Range end must be an integer"))?;
+
+ Ok((start, end))
+ })
+ .collect::<Result<Vec<(i64, i64)>, eyre::Error>>()?;
+
+ Ok(Self { history_id, ranges })
+ }
+}
+
+impl PermissibleToolCall for AtuinOutputToolCall {
+ fn target_dir(&self) -> Option<&Path> {
+ None
+ }
+
+ fn matches_rule(&self, rule: &Rule) -> bool {
+ rule.tool == "AtuinOutput"
+ }
+}
+
+fn format_output_lines_for_llm(lines: &[atuin_daemon::semantic::OutputLine]) -> String {
+ let width = lines
+ .iter()
+ .map(|line| line.line_number)
+ .max()
+ .unwrap_or(1)
+ .max(1)
+ .ilog10() as usize
+ + 1;
+ let mut formatted = Vec::with_capacity(lines.len());
+ let mut previous_line_number = None;
+
+ for line in lines {
+ if let Some(previous) = previous_line_number {
+ let skipped = line.line_number.saturating_sub(previous + 1);
+ if skipped > 0 {
+ formatted.push(format!("[...skipped {skipped} lines...]"));
+ }
+ }
+
+ formatted.push(format!("{:>width$}\t{}", line.line_number, line.content));
+ previous_line_number = Some(line.line_number);
+ }
+
+ formatted.join("\n")
+}
+
+impl AtuinOutputToolCall {
+ pub(crate) async fn execute(&self) -> ToolOutcome {
+ let settings = match atuin_client::settings::Settings::new() {
+ Ok(settings) => settings,
+ Err(e) => return ToolOutcome::Error(format!("Failed to load Atuin settings: {e}")),
+ };
+
+ let mut client = match atuin_daemon::SemanticClient::from_settings(&settings).await {
+ Ok(client) => client,
+ Err(e) => return ToolOutcome::Error(format!("Failed to connect to Atuin daemon: {e}")),
+ };
+
+ let history_id = self.history_id.as_simple().to_string();
+ let response = match client
+ .command_output(history_id.clone(), self.ranges.clone())
+ .await
+ {
+ Ok(response) => response,
+ Err(e) => return ToolOutcome::Error(format!("Failed to fetch command output: {e}")),
+ };
+
+ if !response.found {
+ return ToolOutcome::Success(format!(
+ "No captured output found for history ID {history_id}."
+ ));
+ }
+
+ if response.total_lines == 0 {
+ return ToolOutcome::Success(format!(
+ "Captured output for history ID {history_id} is empty."
+ ));
+ }
+
+ let output = format_output_lines_for_llm(&response.lines);
+ if output.is_empty() {
+ return ToolOutcome::Success(format!(
+ "No lines selected from captured output for history ID {history_id}."
+ ));
+ }
+
+ let total_output = if response.output_truncated {
+ format!(
+ "{} bytes captured, {} bytes observed before truncation, {} lines",
+ response.total_bytes, response.output_observed_bytes, response.total_lines
+ )
+ } else {
+ format!(
+ "{} bytes, {} lines",
+ response.total_bytes, response.total_lines
+ )
+ };
+
+ ToolOutcome::Success(format!(
+ "History ID: {history_id}\nTotal output: {total_output}\nSelected output:\n{output}"
+ ))
+ }
+}
+
+#[derive(Debug, Clone)]
pub(crate) struct LoadSkillToolCall {
pub name: String,
}
@@ -1239,7 +1354,7 @@ impl TryFrom<&serde_json::Value> for LoadSkillToolCall {
}
}
-impl PermissableToolCall for LoadSkillToolCall {
+impl PermissibleToolCall for LoadSkillToolCall {
fn target_dir(&self) -> Option<&Path> {
None
}
@@ -1286,6 +1401,52 @@ mod tests {
// ── Cross-platform tests ──
#[test]
+ fn atuin_output_ranges_are_optional() {
+ let input = serde_json::json!({
+ "history_id": "018f0000000070008000000000000000"
+ });
+
+ let call = AtuinOutputToolCall::try_from(&input).unwrap();
+
+ assert_eq!(
+ call.history_id.as_simple().to_string(),
+ "018f0000000070008000000000000000"
+ );
+ assert!(call.ranges.is_empty());
+ }
+
+ #[test]
+ fn atuin_output_parses_line_ranges() {
+ let input = serde_json::json!({
+ "history_id": "018f0000000070008000000000000000",
+ "ranges": [[0, 30], [-100, -1]]
+ });
+
+ let call = AtuinOutputToolCall::try_from(&input).unwrap();
+
+ assert_eq!(call.ranges, vec![(0, 30), (-100, -1)]);
+ }
+
+ #[test]
+ fn atuin_output_formats_lines_like_read_file() {
+ let lines = vec![
+ atuin_daemon::semantic::OutputLine {
+ line_number: 98,
+ content: "near end".to_string(),
+ },
+ atuin_daemon::semantic::OutputLine {
+ line_number: 100,
+ content: "end".to_string(),
+ },
+ ];
+
+ assert_eq!(
+ format_output_lines_for_llm(&lines),
+ " 98\tnear end\n[...skipped 1 lines...]\n100\tend"
+ );
+ }
+
+ #[test]
fn no_scope_matches_everything() {
assert!(read_tool("any/path.txt").matches_rule(&read_rule(None)));
assert!(write_tool("any/path.txt").matches_rule(&write_rule(None)));
@@ -1996,31 +2157,3 @@ mod tests {
}
}
}
-
-fn format_duration(nanos: i64) -> String {
- if nanos <= 0 {
- return String::new();
- }
-
- let total_secs = nanos / 1_000_000_000;
- let millis = (nanos % 1_000_000_000) / 1_000_000;
-
- if total_secs >= 3600 {
- let hours = total_secs / 3600;
- let mins = (total_secs % 3600) / 60;
- let secs = total_secs % 60;
- format!(", {hours}h{mins}m{secs}s")
- } else if total_secs >= 60 {
- let mins = total_secs / 60;
- let secs = total_secs % 60;
- format!(", {mins}m{secs}s")
- } else if total_secs > 0 {
- if millis > 0 {
- format!(", {total_secs}.{millis:03}s")
- } else {
- format!(", {total_secs}s")
- }
- } else {
- format!(", {millis}ms")
- }
-}
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs
index 73dc2ad7..b594cedf 100644
--- a/crates/atuin-ai/src/tui/view/mod.rs
+++ b/crates/atuin-ai/src/tui/view/mod.rs
@@ -168,6 +168,7 @@ fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bo
ClientToolCall::Write(tool) => tool.path.display().to_string(),
ClientToolCall::Shell(tool) => tool.command.clone(),
ClientToolCall::AtuinHistory(tool) => tool.query.clone(),
+ ClientToolCall::AtuinOutput(tool) => tool.history_id.to_string(),
ClientToolCall::LoadSkill(tool) => format!("skill: {}", tool.name),
};
diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs
index c74395b8..aa1f55fa 100644
--- a/crates/atuin-ai/src/tui/view/turn.rs
+++ b/crates/atuin-ai/src/tui/view/turn.rs
@@ -495,6 +495,7 @@ impl<'a> TurnBuilder<'a> {
query: history.query.clone(),
filter_modes: history.filter_modes.clone(),
},
+ ClientToolCall::AtuinOutput(_) => ToolRenderData::Remote,
ClientToolCall::LoadSkill(skill) => ToolRenderData::SkillLoad {
_name: skill.name.clone(),
},