fix(desktop): clean ANSI escapes from agent log tail + drop dead AppHandle plumbing (#756)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-27 09:12:41 -07:00
committed by GitHub
parent f1e25b21da
commit 9823ff52e2
8 changed files with 65 additions and 34 deletions
+19
View File
@@ -5193,6 +5193,7 @@ dependencies = [
"sprout-core",
"sprout-persona",
"sprout-sdk",
"strip-ansi-escapes",
"tar",
"tauri",
"tauri-build",
@@ -5300,6 +5301,15 @@ dependencies = [
"quote",
]
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -6793,6 +6803,15 @@ dependencies = [
"libc",
]
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "walkdir"
version = "2.5.0"
+1
View File
@@ -72,5 +72,6 @@ earshot = "1.0"
rubato = "3.0"
audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"
[dev-dependencies]
@@ -1,5 +1,5 @@
use std::io::Read;
use tauri::{AppHandle, State};
use tauri::State;
use crate::{
app_state::AppState,
@@ -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;
@@ -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);
@@ -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
@@ -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),
}
}
@@ -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());
+1 -1
View File
@@ -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 \
@@ -1,8 +1,6 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::AppHandle;
use crate::managed_agents::{
AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo,
};
@@ -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"),
@@ -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())
@@ -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.
@@ -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() {
@@ -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);
}
@@ -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(),
+10 -11
View File
@@ -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());
@@ -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);
@@ -181,8 +181,11 @@ 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"))
}
@@ -190,3 +193,15 @@ pub fn read_log_tail(path: &Path, max_lines: usize) -> Result<String, String> {
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"
);
}
}