Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ earshot = "1.0"
rubato = "3.0"
audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"

[dev-dependencies]
11 changes: 5 additions & 6 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::io::Read;
use tauri::{AppHandle, State};
use tauri::State;

use crate::{
app_state::AppState,
Expand Down Expand Up @@ -64,7 +64,7 @@ fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResul

// Phase 1: Install CLI if missing and commands are available.
if let Some(cli) = provider.underlying_cli {
if crate::managed_agents::resolve_command(cli, None).is_none() {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in provider.cli_install_commands {
let result = run_install_command("cli", cmd);
let success = result.success;
Expand All @@ -83,7 +83,7 @@ fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResul
let adapter_found = provider
.commands
.iter()
.any(|cmd| crate::managed_agents::resolve_command(cmd, None).is_some());
.any(|cmd| crate::managed_agents::resolve_command(cmd).is_some());
if !adapter_found {
for cmd in provider.adapter_install_commands {
let result = run_install_command("adapter", cmd);
Expand Down Expand Up @@ -286,7 +286,6 @@ fn truncate_output(s: String) -> String {
#[tauri::command]
pub fn discover_managed_agent_prereqs(
input: DiscoverManagedAgentPrereqsRequest,
app: AppHandle,
) -> ManagedAgentPrereqsInfo {
let acp_command = input
.acp_command
Expand All @@ -302,8 +301,8 @@ pub fn discover_managed_agent_prereqs(
.unwrap_or(DEFAULT_MCP_COMMAND);

ManagedAgentPrereqsInfo {
acp: command_availability(acp_command, Some(&app)),
mcp: command_availability(mcp_command, Some(&app)),
acp: command_availability(acp_command),
mcp: command_availability(mcp_command),
}
}

Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ pub async fn get_agent_models(
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;

let resolved = resolve_command(&record.acp_command, Some(&app))
let resolved = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;

let args = normalize_agent_args(&record.agent_command, record.agent_args.clone());

let resolved_agent = resolve_command(&record.agent_command, Some(&app))
let resolved_agent = resolve_command(&record.agent_command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());

Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ pub async fn upload_media(
/// (login shell PATH, /opt/homebrew/bin, /usr/local/bin, etc.).
/// Returns the resolved absolute path on success.
fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
let ffmpeg_path = resolve_command("ffmpeg", None).ok_or_else(|| {
let ffmpeg_path = resolve_command("ffmpeg").ok_or_else(|| {
"ffmpeg is required for video uploads but was not found.\n\n\
Install it:\n \
macOS: brew install ffmpeg\n \
Expand Down
22 changes: 10 additions & 12 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
use std::path::{Path, PathBuf};
use std::process::Command;

use tauri::AppHandle;

use crate::managed_agents::{
AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo,
};
Expand Down Expand Up @@ -234,7 +232,7 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<Strin
normalized
}

fn command_search_dirs(app: Option<&AppHandle>) -> Vec<PathBuf> {
fn command_search_dirs() -> Vec<PathBuf> {
let mut dirs = vec![
workspace_root_dir().join("target/release"),
workspace_root_dir().join("target/debug"),
Expand Down Expand Up @@ -262,14 +260,14 @@ fn command_search_dirs(app: Option<&AppHandle>) -> Vec<PathBuf> {
unique
}

fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
fn resolve_workspace_command(command: &str) -> Option<PathBuf> {
if command_looks_like_path(command) {
let path = PathBuf::from(command);
return path.exists().then_some(path);
}

let file_name = executable_basename(command);
command_search_dirs(app)
command_search_dirs()
.into_iter()
.map(|dir| dir.join(&file_name))
.find(|candidate| candidate.exists())
Expand All @@ -286,7 +284,7 @@ fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String
/// Resolve a command to an absolute path, caching results for the app lifetime.
/// The cache eliminates redundant login-shell spawns when multiple agents share
/// the same binaries (e.g. `npx`, `uvx`).
pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
pub fn resolve_command(command: &str) -> Option<PathBuf> {
let cache = resolve_cache();

// Fast path: return cached result without allocating a key.
Expand All @@ -297,7 +295,7 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf
}

// Slow path: resolve and cache.
let result = resolve_command_uncached(command, app);
let result = resolve_command_uncached(command);

if result.is_some() {
if let Ok(mut guard) = cache.lock() {
Expand All @@ -314,8 +312,8 @@ pub fn clear_resolve_cache() {
guard.clear();
}

fn resolve_command_uncached(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command, app) {
fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command) {
return Some(path);
}

Expand Down Expand Up @@ -393,11 +391,11 @@ pub fn login_shell_path() -> Option<String> {
}

fn find_command(command: &str) -> Option<PathBuf> {
resolve_command(command, None)
resolve_command(command)
}

pub fn command_availability(command: &str, app: Option<&AppHandle>) -> CommandAvailabilityInfo {
let resolved_path = resolve_command(command, app).map(|path| path.display().to_string());
pub fn command_availability(command: &str) -> CommandAvailabilityInfo {
let resolved_path = resolve_command(command).map(|path| path.display().to_string());
CommandAvailabilityInfo {
command: command.to_string(),
available: resolved_path.is_some(),
Expand Down
21 changes: 10 additions & 11 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,19 +527,18 @@ pub fn spawn_agent_child(
.try_clone()
.map_err(|error| format!("failed to clone log handle: {error}"))?;
let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone());
let resolved_acp_command = resolve_command(&record.acp_command, Some(app))
let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
let resolved_mcp_command: Option<std::path::PathBuf> = if record.mcp_command.is_empty() {
None
} else {
Some(
resolve_command(&record.mcp_command, Some(app)).ok_or_else(|| {
let resolved_mcp_command: Option<std::path::PathBuf> =
if record.mcp_command.is_empty() {
None
} else {
Some(resolve_command(&record.mcp_command).ok_or_else(|| {
missing_command_message(&record.mcp_command, "MCP server command")
})?,
)
};
})?)
};
// Resolve agent command to a full path (DMG launches have minimal PATH).
let resolved_agent_command = resolve_command(&record.agent_command, Some(app))
let resolved_agent_command = resolve_command(&record.agent_command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());

Expand Down Expand Up @@ -698,7 +697,7 @@ pub fn spawn_agent_child(
// interfere with other remotes (e.g. GitHub).
//
// NOSTR_PRIVATE_KEY mirrors SPROUT_PRIVATE_KEY — keep in sync.
if let Some(cred_helper) = resolve_command("git-credential-nostr", Some(app)) {
if let Some(cred_helper) = resolve_command("git-credential-nostr") {
let relay_http_url = crate::relay::relay_http_base_url(&record.relay_url);

command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec);
Expand Down
19 changes: 17 additions & 2 deletions desktop/src-tauri/src/managed_agents/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,27 @@ pub fn read_log_tail(path: &Path, max_lines: usize) -> Result<String, String> {
newline_count = bytecount_newlines(&buf);
}

let text = String::from_utf8_lossy(&buf);
let lines: Vec<&str> = text.lines().collect();
// Strip ANSI escapes here (not in the harness) so the desktop log view
// renders cleanly while terminals and other tools still get the colors
// sprout-acp emits.
let cleaned = strip_ansi_escapes::strip_str(&String::from_utf8_lossy(&buf));
let lines: Vec<&str> = cleaned.lines().collect();
let start = lines.len().saturating_sub(max_lines);
Ok(lines[start..].join("\n"))
}

fn bytecount_newlines(buf: &[u8]) -> usize {
buf.iter().filter(|&&b| b == b'\n').count()
}

#[cfg(test)]
mod tests {
#[test]
fn strips_ansi_from_typical_tracing_line() {
let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2msprout_acp\x1b[0m\x1b[2m:\x1b[0m starting";
assert_eq!(
strip_ansi_escapes::strip_str(input),
"2026-05-27T15:16:32 INFO sprout_acp: starting"
);
}
}