diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-06-08 09:12:45 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-08 09:12:45 -0700 |
| commit | bcdf8c8cde31e826000f1b2d6eeaebdd865a07c1 (patch) | |
| tree | f62f66e4dede22ce73ea5dafe69881d6af9b3101 /crates/atuin-ai | |
| parent | chore(deps): bump debian from bookworm-20260421-slim to bookworm-20260518-sli... (diff) | |
| download | atuin-bcdf8c8cde31e826000f1b2d6eeaebdd865a07c1.zip | |
feat: Capture command output + expose to new `atuin_output` tool (#3510)
Diffstat (limited to 'crates/atuin-ai')
| -rw-r--r-- | crates/atuin-ai/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/context.rs | 27 | ||||
| -rw-r--r-- | crates/atuin-ai/src/driver.rs | 16 | ||||
| -rw-r--r-- | crates/atuin-ai/src/history_format.rs | 120 | ||||
| -rw-r--r-- | crates/atuin-ai/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-ai/src/permissions/check.rs | 6 | ||||
| -rw-r--r-- | crates/atuin-ai/src/stream.rs | 13 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tools/descriptor.rs | 10 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tools/mod.rs | 281 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 1 |
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(), }, |
