mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
Generated
+19
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user