diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index 4e73a2f..6ab638b 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -8,6 +8,8 @@ use crate::db::error::DbError; pub enum AppErrorCode { Unknown, InvalidInput, + ConfigurationMissing, + ConfigurationInvalid, NotFound, AlreadyExists, PermissionDenied, @@ -81,3 +83,15 @@ impl From for AppCommandError { Self::db(value) } } + +impl From for AppCommandError { + fn from(value: String) -> Self { + Self::new(AppErrorCode::Unknown, "Operation failed").with_detail(value) + } +} + +impl From<&str> for AppCommandError { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 4fea278..54ce02f 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -148,13 +148,18 @@ fn parse_count_from_output(stdout: &[u8]) -> Option { String::from_utf8_lossy(stdout).trim().parse::().ok() } -async fn get_head_hash(path: &str) -> Result, String> { +fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError { + let stderr = String::from_utf8_lossy(stderr).trim().to_string(); + AppCommandError::external_command(format!("git {operation} failed"), stderr) +} + +async fn get_head_hash(path: &str) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["rev-parse", "HEAD"]) .current_dir(path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { return Ok(None); @@ -167,17 +172,16 @@ async fn get_head_hash(path: &str) -> Result, String> { Ok(Some(head)) } -async fn count_files_in_commit(path: &str, commit: &str) -> Result { +async fn count_files_in_commit(path: &str, commit: &str) -> Result { let output = crate::process::tokio_command("git") .args(["show", "--name-only", "--pretty=format:", commit]) .current_dir(path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git show failed: {}", stderr.trim())); + return Err(git_command_error("show", &output.stderr)); } Ok(count_non_empty_lines(&String::from_utf8_lossy( @@ -185,18 +189,21 @@ async fn count_files_in_commit(path: &str, commit: &str) -> Result Result { +async fn count_changed_files_between( + path: &str, + base: &str, + head: &str, +) -> Result { let range = format!("{}..{}", base, head); let output = crate::process::tokio_command("git") .args(["diff", "--name-only", &range]) .current_dir(path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git diff failed: {}", stderr.trim())); + return Err(git_command_error("diff", &output.stderr)); } Ok(count_non_empty_lines(&String::from_utf8_lossy( @@ -337,8 +344,8 @@ pub async fn save_folder_opened_conversations( } #[tauri::command] -pub async fn create_folder_directory(path: String) -> Result<(), String> { - std::fs::create_dir_all(&path).map_err(|e| e.to_string()) +pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError> { + std::fs::create_dir_all(&path).map_err(AppCommandError::io) } #[tauri::command] @@ -427,13 +434,13 @@ fn classify_git_clone_error(stderr: &str) -> AppCommandError { } #[tauri::command] -pub async fn get_git_branch(path: String) -> Result, String> { +pub async fn get_git_branch(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { return Ok(None); @@ -447,23 +454,22 @@ pub async fn get_git_branch(path: String) -> Result, String> { } #[tauri::command] -pub async fn git_init(path: String) -> Result<(), String> { +pub async fn git_init(path: String) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["init"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git init failed: {}", stderr.trim())); + return Err(git_command_error("init", &output.stderr)); } Ok(()) } #[tauri::command] -pub async fn git_pull(path: String) -> Result { +pub async fn git_pull(path: String) -> Result { let head_before = get_head_hash(&path).await?; let output = crate::process::tokio_command("git") @@ -471,11 +477,10 @@ pub async fn git_pull(path: String) -> Result { .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git pull failed: {}", stderr.trim())); + return Err(git_command_error("pull", &output.stderr)); } let head_after = get_head_hash(&path).await?; @@ -491,23 +496,22 @@ pub async fn git_pull(path: String) -> Result { } #[tauri::command] -pub async fn git_fetch(path: String) -> Result { +pub async fn git_fetch(path: String) -> Result { let output = crate::process::tokio_command("git") .args(["fetch", "--all"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git fetch failed: {}", stderr.trim())); + return Err(git_command_error("fetch --all", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) } #[tauri::command] -pub async fn git_push(path: String) -> Result { +pub async fn git_push(path: String) -> Result { let pushed_commits = estimate_push_commit_count(&path).await; // Check if the current branch has an upstream configured @@ -516,7 +520,7 @@ pub async fn git_push(path: String) -> Result { .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; let has_upstream = upstream_check.status.success(); @@ -527,7 +531,7 @@ pub async fn git_push(path: String) -> Result { .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; let branch = String::from_utf8_lossy(&branch_output.stdout) .trim() .to_string(); @@ -537,19 +541,18 @@ pub async fn git_push(path: String) -> Result { .current_dir(&path) .output() .await - .map_err(|e| e.to_string())? + .map_err(AppCommandError::io)? } else { crate::process::tokio_command("git") .args(["push"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())? + .map_err(AppCommandError::io)? }; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git push failed: {}", stderr.trim())); + return Err(git_command_error("push", &output.stderr)); } Ok(GitPushResult { @@ -563,7 +566,7 @@ pub async fn git_new_branch( path: String, branch_name: String, start_point: Option, -) -> Result<(), String> { +) -> Result<(), AppCommandError> { let mut args = vec!["checkout".to_string(), "-b".to_string(), branch_name]; if let Some(start_point) = start_point { let trimmed = start_point.trim(); @@ -577,11 +580,10 @@ pub async fn git_new_branch( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git checkout -b failed: {}", stderr.trim())); + return Err(git_command_error("checkout -b", &output.stderr)); } Ok(()) } @@ -591,7 +593,7 @@ pub async fn git_worktree_add( path: String, branch_name: String, worktree_path: String, -) -> Result<(), String> { +) -> Result<(), AppCommandError> { // 校验分支是否已存在 let check = crate::process::tokio_command("git") .args([ @@ -602,14 +604,22 @@ pub async fn git_worktree_add( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if check.status.success() { - return Err(format!("分支 '{}' 已存在", branch_name)); + return Err(AppCommandError::new( + AppErrorCode::AlreadyExists, + "Branch already exists", + ) + .with_detail(branch_name)); } // 校验目录是否已存在 if std::path::Path::new(&worktree_path).exists() { - return Err(format!("目录 '{}' 已存在", worktree_path)); + return Err(AppCommandError::new( + AppErrorCode::AlreadyExists, + "Worktree directory already exists", + ) + .with_detail(worktree_path)); } // 执行 git worktree add -b @@ -618,43 +628,40 @@ pub async fn git_worktree_add( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git worktree add failed: {}", stderr.trim())); + return Err(git_command_error("worktree add", &output.stderr)); } Ok(()) } #[tauri::command] -pub async fn git_checkout(path: String, branch_name: String) -> Result<(), String> { +pub async fn git_checkout(path: String, branch_name: String) -> Result<(), AppCommandError> { let output = crate::process::tokio_command("git") .args(["checkout", &branch_name]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git checkout failed: {}", stderr.trim())); + return Err(git_command_error("checkout", &output.stderr)); } Ok(()) } #[tauri::command] -pub async fn git_list_branches(path: String) -> Result, String> { +pub async fn git_list_branches(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git branch failed: {}", stderr.trim())); + return Err(git_command_error("branch", &output.stderr)); } let branches = String::from_utf8_lossy(&output.stdout) @@ -666,49 +673,46 @@ pub async fn git_list_branches(path: String) -> Result, String> { } #[tauri::command] -pub async fn git_stash(path: String) -> Result { +pub async fn git_stash(path: String) -> Result { let output = crate::process::tokio_command("git") .args(["stash"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git stash failed: {}", stderr.trim())); + return Err(git_command_error("stash", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[tauri::command] -pub async fn git_stash_pop(path: String) -> Result { +pub async fn git_stash_pop(path: String) -> Result { let output = crate::process::tokio_command("git") .args(["stash", "pop"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git stash pop failed: {}", stderr.trim())); + return Err(git_command_error("stash pop", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[tauri::command] -pub async fn git_status(path: String) -> Result, String> { +pub async fn git_status(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["status", "--porcelain=v1", "-uall"]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git status failed: {}", stderr.trim())); + return Err(git_command_error("status", &output.stderr)); } let entries = String::from_utf8_lossy(&output.stdout) @@ -724,7 +728,7 @@ pub async fn git_status(path: String) -> Result, String> { } #[tauri::command] -pub async fn git_is_tracked(path: String, file: String) -> Result { +pub async fn git_is_tracked(path: String, file: String) -> Result { let literal_file = to_git_literal_pathspec(&file); let output = crate::process::tokio_command("git") .args(["ls-files", "--error-unmatch", "--"]) @@ -732,13 +736,13 @@ pub async fn git_is_tracked(path: String, file: String) -> Result .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; Ok(output.status.success()) } #[tauri::command] -pub async fn git_diff(path: String, file: Option) -> Result { +pub async fn git_diff(path: String, file: Option) -> Result { let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec!["diff".to_string(), "HEAD".to_string()]; if let Some(ref f) = literal_file { @@ -751,7 +755,7 @@ pub async fn git_diff(path: String, file: Option) -> Result) -> Result, -) -> Result { +) -> Result { let target_branch = branch.trim(); if target_branch.is_empty() { - return Err("Branch name cannot be empty".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Branch name cannot be empty", + )); } let literal_file = file.as_deref().map(to_git_literal_pathspec); @@ -799,15 +806,14 @@ pub async fn git_diff_with_branch( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "git diff {} failed: {}", - target_branch, - stderr.trim() - )); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err( + AppCommandError::external_command("git diff failed", stderr) + .with_detail(format!("branch={target_branch}")), + ); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) @@ -818,7 +824,7 @@ pub async fn git_show_diff( path: String, commit: String, file: Option, -) -> Result { +) -> Result { let literal_file = file.as_deref().map(to_git_literal_pathspec); let mut args = vec![ "show".to_string(), @@ -836,11 +842,10 @@ pub async fn git_show_diff( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git show failed: {}", stderr.trim())); + return Err(git_command_error("show", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) @@ -851,7 +856,7 @@ pub async fn git_show_file( path: String, file: String, ref_name: Option, -) -> Result { +) -> Result { let git_ref = ref_name.unwrap_or_else(|| "HEAD".to_string()); let file_spec = format!("{}:{}", git_ref, file); @@ -860,7 +865,7 @@ pub async fn git_show_file( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { // File doesn't exist at this ref (e.g. new/untracked file) — return empty @@ -869,7 +874,11 @@ pub async fn git_show_file( let bytes = &output.stdout; if bytes.iter().take(2048).any(|b| *b == 0) { - return Err("Binary files are not supported".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "Binary files are not supported", + ) + .with_detail(file_spec)); } Ok(String::from_utf8_lossy(bytes).to_string()) @@ -882,7 +891,7 @@ pub async fn git_commit( path: String, message: String, files: Vec, -) -> Result { +) -> Result { // Stage selected files let mut add_args = vec!["add".to_string(), "--".to_string()]; add_args.extend(files.iter().map(|file| to_git_literal_pathspec(file))); @@ -892,11 +901,10 @@ pub async fn git_commit( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !add_output.status.success() { - let stderr = String::from_utf8_lossy(&add_output.stderr); - return Err(format!("git add failed: {}", stderr.trim())); + return Err(git_command_error("add", &add_output.stderr)); } // Commit @@ -905,11 +913,10 @@ pub async fn git_commit( .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !commit_output.status.success() { - let stderr = String::from_utf8_lossy(&commit_output.stderr); - return Err(format!("git commit failed: {}", stderr.trim())); + return Err(git_command_error("commit", &commit_output.stderr)); } let committed_files = count_files_in_commit(&path, "HEAD") @@ -934,10 +941,13 @@ pub async fn git_commit( } #[tauri::command] -pub async fn git_rollback_file(path: String, file: String) -> Result<(), String> { +pub async fn git_rollback_file(path: String, file: String) -> Result<(), AppCommandError> { let target = file.trim(); if target.is_empty() { - return Err("File path cannot be empty".to_string()); + return Err(AppCommandError::new( + AppErrorCode::InvalidInput, + "File path cannot be empty", + )); } let literal_file = to_git_literal_pathspec(target); @@ -953,7 +963,7 @@ pub async fn git_rollback_file(path: String, file: String) -> Result<(), String> .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if restore_output.status.success() { return Ok(()); @@ -969,7 +979,10 @@ pub async fn git_rollback_file(path: String, file: String) -> Result<(), String> && !restore_stderr_lower.contains("did you mean"); if supports_restore { - return Err(format!("git restore failed: {}", restore_stderr)); + return Err(AppCommandError::external_command( + "git restore failed", + restore_stderr, + )); } let _ = crate::process::tokio_command("git") @@ -977,25 +990,24 @@ pub async fn git_rollback_file(path: String, file: String) -> Result<(), String> .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; let checkout_output = crate::process::tokio_command("git") .args(["checkout", "--", &literal_file]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !checkout_output.status.success() { - let checkout_stderr = String::from_utf8_lossy(&checkout_output.stderr); - return Err(format!("git checkout failed: {}", checkout_stderr.trim())); + return Err(git_command_error("checkout --", &checkout_output.stderr)); } Ok(()) } #[tauri::command] -pub async fn git_add_files(path: String, files: Vec) -> Result<(), String> { +pub async fn git_add_files(path: String, files: Vec) -> Result<(), AppCommandError> { if files.is_empty() { return Ok(()); } @@ -1008,18 +1020,17 @@ pub async fn git_add_files(path: String, files: Vec) -> Result<(), Strin .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git add failed: {}", stderr.trim())); + return Err(git_command_error("add", &output.stderr)); } Ok(()) } #[tauri::command] -pub async fn git_list_all_branches(path: String) -> Result { +pub async fn git_list_all_branches(path: String) -> Result { let local_fut = crate::process::tokio_command("git") .args(["branch", "--format=%(refname:short)"]) .current_dir(&path) @@ -1037,10 +1048,9 @@ pub async fn git_list_all_branches(path: String) -> Result = String::from_utf8_lossy(&local_output.stdout) @@ -1094,14 +1104,14 @@ pub async fn git_list_all_branches(path: String) -> Result Result { +pub async fn git_merge(path: String, branch_name: String) -> Result { // Count commits to be merged before performing merge let count_output = crate::process::tokio_command("git") .args(["rev-list", "--count", &format!("HEAD..{}", branch_name)]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; let merged_commits = if count_output.status.success() { String::from_utf8_lossy(&count_output.stdout) @@ -1117,27 +1127,25 @@ pub async fn git_merge(path: String, branch_name: String) -> Result Result { +pub async fn git_rebase(path: String, branch_name: String) -> Result { let output = crate::process::tokio_command("git") .args(["rebase", &branch_name]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git rebase failed: {}", stderr.trim())); + return Err(git_command_error("rebase", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } @@ -1147,18 +1155,17 @@ pub async fn git_delete_branch( path: String, branch_name: String, force: bool, -) -> Result { +) -> Result { let flag = if force { "-D" } else { "-d" }; let output = crate::process::tokio_command("git") .args(["branch", flag, &branch_name]) .current_dir(&path) .output() .await - .map_err(|e| e.to_string())?; + .map_err(AppCommandError::io)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git branch {} failed: {}", flag, stderr.trim())); + return Err(git_command_error(&format!("branch {flag}"), &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index e9ef45c..90d8c88 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -34,13 +34,13 @@ fn normalize_proxy_settings( .filter(|value| !value.is_empty()) .ok_or_else(|| { AppCommandError::new( - AppErrorCode::InvalidInput, + AppErrorCode::ConfigurationMissing, "Proxy URL is required when proxy is enabled", ) })?; reqwest::Proxy::all(proxy_url).map_err(|e| { - AppCommandError::new(AppErrorCode::InvalidInput, "Invalid proxy URL") + AppCommandError::new(AppErrorCode::ConfigurationInvalid, "Invalid proxy URL") .with_detail(e.to_string()) })?; @@ -63,7 +63,7 @@ pub(crate) async fn load_system_proxy_settings( let parsed = serde_json::from_str::(&raw).map_err(|e| { AppCommandError::new( - AppErrorCode::InvalidInput, + AppErrorCode::ConfigurationInvalid, "Failed to parse stored proxy settings", ) .with_detail(e.to_string()) @@ -84,7 +84,7 @@ pub(crate) async fn load_system_language_settings( serde_json::from_str::(&raw).map_err(|e| { AppCommandError::new( - AppErrorCode::InvalidInput, + AppErrorCode::ConfigurationInvalid, "Failed to parse stored language settings", ) .with_detail(e.to_string()) @@ -116,13 +116,7 @@ pub async fn update_system_proxy_settings( .await .map_err(AppCommandError::from)?; - proxy::apply_system_proxy_settings(&normalized).map_err(|e| { - AppCommandError::new( - AppErrorCode::ExternalCommandFailed, - "Failed to apply system proxy settings", - ) - .with_detail(e) - })?; + proxy::apply_system_proxy_settings(&normalized)?; Ok(normalized) } diff --git a/src-tauri/src/network/proxy.rs b/src-tauri/src/network/proxy.rs index 204bac5..1d33e64 100644 --- a/src-tauri/src/network/proxy.rs +++ b/src-tauri/src/network/proxy.rs @@ -1,3 +1,4 @@ +use crate::app_error::{AppCommandError, AppErrorCode}; use crate::models::SystemProxySettings; const PROXY_ENV_KEYS: [&str; 6] = [ @@ -9,14 +10,19 @@ const PROXY_ENV_KEYS: [&str; 6] = [ "all_proxy", ]; -pub fn apply_system_proxy_settings(settings: &SystemProxySettings) -> Result<(), String> { +pub fn apply_system_proxy_settings(settings: &SystemProxySettings) -> Result<(), AppCommandError> { if settings.enabled { let proxy_url = settings .proxy_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?; + .ok_or_else(|| { + AppCommandError::new( + AppErrorCode::ConfigurationMissing, + "Proxy URL is required when proxy is enabled", + ) + })?; for key in PROXY_ENV_KEYS { unsafe { diff --git a/src/components/chat/live-message-block.tsx b/src/components/chat/live-message-block.tsx index fed2396..7fc580d 100644 --- a/src/components/chat/live-message-block.tsx +++ b/src/components/chat/live-message-block.tsx @@ -15,8 +15,16 @@ export const LiveMessageBlock = memo(function LiveMessageBlock({ message, }: LiveMessageBlockProps) { const t = useTranslations("Folder.chat.liveMessageBlock") + const sharedT = useTranslations("Folder.chat.shared") const hasContent = message.content.length > 0 - const adapted = useMemo(() => adaptLiveMessageFromAcp(message), [message]) + const adapted = useMemo( + () => + adaptLiveMessageFromAcp(message, { + toolCallFailedText: sharedT("toolCallFailed"), + planUpdatedText: sharedT("planUpdated"), + }), + [message, sharedT] + ) return ( diff --git a/src/components/chat/welcome-input-panel.tsx b/src/components/chat/welcome-input-panel.tsx index c96cff2..fe7d020 100644 --- a/src/components/chat/welcome-input-panel.tsx +++ b/src/components/chat/welcome-input-panel.tsx @@ -87,6 +87,8 @@ export function WelcomeInputPanel({ isActive = true, }: WelcomeInputPanelProps) { const t = useTranslations("Folder.chat.welcomeInputPanel") + const tabT = useTranslations("Folder.tabContext") + const sharedT = useTranslations("Folder.chat.shared") const fallbackContextId = useMemo(() => crypto.randomUUID(), []) const contextKey = tabId ?? `new-${fallbackContextId}` @@ -159,7 +161,10 @@ export function WelcomeInputPanel({ const detail = await getFolderConversation(conversationId) if (refreshSeq !== statsRefreshSeqRef.current) return - const messages = adaptMessageTurns(detail.turns) + const messages = adaptMessageTurns(detail.turns, { + attachedResources: sharedT("attachedResources"), + toolCallFailed: sharedT("toolCallFailed"), + }) const stats = detail.session_stats ?? null latestMessages = messages latestStats = stats @@ -196,7 +201,7 @@ export function WelcomeInputPanel({ applySessionStats(latestStats) } }, - [applySessionStats, hasAssistantUsage, hasTokenStats] + [applySessionStats, hasAssistantUsage, hasTokenStats, sharedT] ) useEffect(() => { @@ -354,6 +359,8 @@ export function WelcomeInputPanel({ if (conn.liveMessage && conn.liveMessage.content.length > 0) { const adapted = adaptLiveMessageFromAcp(conn.liveMessage, { isLiveStreaming: false, + toolCallFailedText: sharedT("toolCallFailed"), + planUpdatedText: sharedT("planUpdated"), }) setHistory((h) => [...h, adapted]) @@ -376,7 +383,7 @@ export function WelcomeInputPanel({ ) } // eslint-disable-next-line react-hooks/exhaustive-deps -- conn.liveMessage, lifecycleSend intentionally omitted: effect only fires on status transitions - }, [connStatus, refreshConversations, refreshConversationFromDb]) + }, [connStatus, refreshConversations, refreshConversationFromDb, sharedT]) // When connection becomes "connected" and we have a pending prompt, send it useEffect(() => { @@ -394,7 +401,7 @@ export function WelcomeInputPanel({ const tid = tabIdRef.current const convId = dbConvIdRef.current const agent = selectedAgentRef.current - const title = convTitleRef.current || "Untitled" + const title = convTitleRef.current || tabT("untitledConversation") const canonicalContextKey = `conv-${agent}-${convId}` // Keep in-flight stream/state attached when this new-conversation view @@ -410,6 +417,7 @@ export function WelcomeInputPanel({ refreshConversations, migrateContextKey, contextKey, + tabT, ]) // Update conversation status on disconnect/error + promote tab @@ -466,11 +474,17 @@ export function WelcomeInputPanel({ // Welcome phase: submit first message. const handleWelcomeSend = useCallback( (draft: PromptDraft, selectedModeId?: string | null) => { - const displayText = getPromptDraftDisplayText(draft) + const displayText = getPromptDraftDisplayText( + draft, + sharedT("attachedResources") + ) const userMsg: AdaptedMessage = { id: crypto.randomUUID(), role: "user", - content: buildUserMessageTextPartsFromDraft(draft), + content: buildUserMessageTextPartsFromDraft( + draft, + sharedT("attachedResources") + ), userResources: extractUserResourcesFromDraft(draft), timestamp: new Date().toISOString(), } @@ -536,6 +550,7 @@ export function WelcomeInputPanel({ trySaveExternalId, applySessionStats, newConversationDraftStorageKey, + sharedT, ] ) @@ -545,7 +560,10 @@ export function WelcomeInputPanel({ const userMsg: AdaptedMessage = { id: crypto.randomUUID(), role: "user", - content: buildUserMessageTextPartsFromDraft(draft), + content: buildUserMessageTextPartsFromDraft( + draft, + sharedT("attachedResources") + ), userResources: extractUserResourcesFromDraft(draft), timestamp: new Date().toISOString(), } @@ -562,7 +580,7 @@ export function WelcomeInputPanel({ statusUpdatedRef.current = false } }, - [lifecycleSend, refreshConversations] + [lifecycleSend, refreshConversations, sharedT] ) const handleOpenAgentsSettings = useCallback(() => { diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index bf85234..67d2452 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -43,6 +43,7 @@ const ExistingConversationView = memo(function ExistingConversationView({ reloadSignal, }: ExistingConversationViewProps) { const t = useTranslations("Folder.conversation") + const sharedT = useTranslations("Folder.chat.shared") const { refreshConversations, folder } = useFolderContext() const contextKey = `conv-${agentType}-${conversationId}` @@ -114,7 +115,10 @@ const ExistingConversationView = memo(function ExistingConversationView({ { id: `pending-${Date.now()}`, role: "user", - content: buildUserMessageTextPartsFromDraft(draft), + content: buildUserMessageTextPartsFromDraft( + draft, + sharedT("attachedResources") + ), userResources: extractUserResourcesFromDraft(draft), timestamp: new Date().toISOString(), }, @@ -125,7 +129,7 @@ const ExistingConversationView = memo(function ExistingConversationView({ statusUpdatedRef.current = false handleSend(draft, selectedModeId) }, - [conversationId, handleSend, refreshConversations] + [conversationId, handleSend, refreshConversations, sharedT] ) // Update status on turn complete diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index c4bca38..5b1a465 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -1660,6 +1660,7 @@ const CODE_FIELDS = new Set([ const HIDDEN_FIELDS = new Set(["dangerouslyDisableSandbox"]) function GenericToolInput({ input }: { input: string }) { + const t = useTranslations("Folder.chat.contentParts") const parsed = tryParseJson(input) if (!parsed) { @@ -1677,6 +1678,9 @@ function GenericToolInput({ input }: { input: string }) { return (
{entries.map(([key, value]) => { + const labelKey = fieldLabelKey(key) + const label = labelKey ? t(labelKey) : key + if (CODE_FIELDS.has(key) && typeof value === "string") { const lang = key === "command" @@ -1685,7 +1689,7 @@ function GenericToolInput({ input }: { input: string }) { ? ("log" as const) : ("log" as const) return ( - + ) @@ -1694,29 +1698,23 @@ function GenericToolInput({ input }: { input: string }) { if (typeof value === "string") { if (value.length > 200) { return ( - +
                   {value}
                 
) } - return + return } if (typeof value === "number" || typeof value === "boolean") { - return ( - - ) + return } if (value !== null && value !== undefined) { return ( - + = { - file_path: "File", - notebook_path: "Notebook", - command: "Command", - cmd: "Command", - old_string: "Old", - new_string: "New", - pattern: "Pattern", - path: "Path", - query: "Query", - url: "URL", - description: "Description", - content: "Content", - new_source: "Source", - prompt: "Prompt", - subject: "Subject", - taskId: "Task ID", - status: "Status", - skill: "Skill", - args: "Args", - offset: "Offset", - limit: "Limit", - glob: "Glob", - type: "Type", - output_mode: "Output", - replace_all: "Replace All", - language: "Language", - timeout: "Timeout", - run_in_background: "Background", - subagent_type: "Agent Type", - libraryName: "Library", - libraryId: "Library ID", - } - return map[key] ?? key +const FIELD_LABEL_KEYS = { + file_path: "field.file", + notebook_path: "field.notebook", + command: "field.command", + cmd: "field.command", + old_string: "field.old", + new_string: "field.new", + pattern: "field.pattern", + path: "field.path", + query: "field.query", + url: "field.url", + description: "field.description", + content: "field.content", + new_source: "field.source", + prompt: "field.prompt", + subject: "field.subject", + taskId: "field.taskId", + status: "field.status", + skill: "field.skill", + args: "field.args", + offset: "field.offset", + limit: "field.limit", + glob: "field.glob", + type: "field.type", + output_mode: "field.output", + replace_all: "field.replaceAll", + language: "field.language", + timeout: "field.timeout", + run_in_background: "field.background", + subagent_type: "field.agentType", + libraryName: "field.library", + libraryId: "field.libraryId", +} as const + +function fieldLabelKey( + key: string +): (typeof FIELD_LABEL_KEYS)[keyof typeof FIELD_LABEL_KEYS] | null { + const translationKey = FIELD_LABEL_KEYS[key as keyof typeof FIELD_LABEL_KEYS] + return translationKey ?? null } function commandOutputFromJsonString(output: string): string | null { diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index aa6b99c..a4a5473 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -169,6 +169,7 @@ export function MessageListView({ isActive = true, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") + const sharedT = useTranslations("Folder.chat.shared") const { detail, loading, error, refetch } = useDbMessageDetail(conversationId) const turnCount = detail?.turns.length ?? 0 @@ -213,8 +214,14 @@ export function MessageListView({ const shouldUseSmoothResize = !(isActive && !loading && detail) const messages = useMemo( - () => (detail ? adaptMessageTurns(detail.turns) : []), - [detail] + () => + detail + ? adaptMessageTurns(detail.turns, { + attachedResources: sharedT("attachedResources"), + toolCallFailed: sharedT("toolCallFailed"), + }) + : [], + [detail, sharedT] ) const groups = useMemo(() => groupAdaptedMessages(messages), [messages]) @@ -234,15 +241,17 @@ export function MessageListView({ ) const resolvedGroups = useMemo( () => - groups.map((group) => resolveMessageGroup(group, t("attachedResources"))), - [groups, t] + groups.map((group) => + resolveMessageGroup(group, sharedT("attachedResources")) + ), + [groups, sharedT] ) const resolvedPendingGroups = useMemo( () => pendingGroups.map((group) => - resolveMessageGroup(group, t("attachedResources")) + resolveMessageGroup(group, sharedT("attachedResources")) ), - [pendingGroups, t] + [pendingGroups, sharedT] ) const showLiveMessage = Boolean( diff --git a/src/components/welcome/error-utils.ts b/src/components/welcome/error-utils.ts index 1a4f9a5..4017fec 100644 --- a/src/components/welcome/error-utils.ts +++ b/src/components/welcome/error-utils.ts @@ -33,6 +33,8 @@ function stripClonePrefix(message: string): string { function mapCommonCodeToKey(code: string): WelcomeErrorKey { switch (code) { case "invalid_input": + case "configuration_missing": + case "configuration_invalid": return "errors.invalidInput" case "not_found": return "errors.notFound" diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index a048281..a4f6aff 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -124,6 +124,8 @@ type Action = contextKey: string tool_call_id: string title: string | null + fallback_title: string + fallback_kind: string status: string | null content: string | null raw_input: string | null @@ -135,6 +137,8 @@ type Action = contextKey: string request_id: string tool_call: unknown + fallback_title: string + fallback_kind: string options: PermissionOptionInfo[] } | { type: "PERMISSION_CLEARED"; contextKey: string } @@ -216,28 +220,28 @@ function serializePermissionToolCall(toolCall: unknown): string | null { } } -function extractPermissionToolTitle(toolCall: unknown): string { +function extractPermissionToolTitle(toolCall: unknown): string | null { const record = asRecord(toolCall) - if (!record) return "Tool" + if (!record) return null const candidates = [record.title, record.tool_name, record.name, record.type] for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim().length > 0) { return candidate } } - return "Tool" + return null } -function extractPermissionToolKind(toolCall: unknown): string { +function extractPermissionToolKind(toolCall: unknown): string | null { const record = asRecord(toolCall) - if (!record) return "tool" + if (!record) return null const candidates = [record.kind, record.tool_name, record.name, record.type] for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim().length > 0) { return candidate } } - return "tool" + return null } function sameModes( @@ -572,8 +576,8 @@ function connectionsReducer( type: "tool_call", info: { tool_call_id: action.tool_call_id, - title: action.title ?? "Tool", - kind: "tool", + title: action.title ?? action.fallback_title, + kind: action.fallback_kind, status: action.status ?? (normalizedRawOutput ? "in_progress" : "pending"), @@ -665,8 +669,12 @@ function connectionsReducer( type: "tool_call", info: { tool_call_id: permissionCallId, - title: extractPermissionToolTitle(action.tool_call), - kind: extractPermissionToolKind(action.tool_call), + title: + extractPermissionToolTitle(action.tool_call) ?? + action.fallback_title, + kind: + extractPermissionToolKind(action.tool_call) ?? + action.fallback_kind, status: "pending", content: null, raw_input: permissionToolInput, @@ -1247,6 +1255,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { contextKey, tool_call_id: e.tool_call_id, title: e.title, + fallback_title: t("toolFallbackTitle"), + fallback_kind: "tool", status: e.status, content: e.content, raw_input: e.raw_input, @@ -1261,6 +1271,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { contextKey, request_id: e.request_id, tool_call: e.tool_call, + fallback_title: t("toolFallbackTitle"), + fallback_kind: "tool", options: e.options, }) break @@ -1322,7 +1334,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { case "error": flushStreamingQueue() dispatch({ type: "ERROR", contextKey, message: e.message }) - pushAlertRef.current("error", "Agent 错误", e.message) + pushAlertRef.current("error", t("eventErrorTitle"), e.message) break case "available_commands": flushStreamingQueue() @@ -1334,7 +1346,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { break } }, - [dispatch, enqueueStreamingAction, flushStreamingQueue] + [dispatch, enqueueStreamingAction, flushStreamingQueue, t] ) // Single global event listener diff --git a/src/hooks/use-connection-lifecycle.ts b/src/hooks/use-connection-lifecycle.ts index e3b1dec..36a4332 100644 --- a/src/hooks/use-connection-lifecycle.ts +++ b/src/hooks/use-connection-lifecycle.ts @@ -50,6 +50,7 @@ export function useConnectionLifecycle({ sessionId, }: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn { const t = useTranslations("Folder.chat.connectionLifecycle") + const sharedT = useTranslations("Folder.chat.shared") const { setActiveKey, touchActivity } = useAcpActions() const { addTask, updateTask, removeTask } = useTaskContext() const conn = useConnection(contextKey) @@ -312,7 +313,10 @@ export function useConnectionLifecycle({ const handleSend = useCallback( (draft: PromptDraft, modeId?: string | null) => { touchActivity(contextKey) - setPendingPromptText(contextKey, getPromptDraftDisplayText(draft)) + setPendingPromptText( + contextKey, + getPromptDraftDisplayText(draft, sharedT("attachedResources")) + ) void (async () => { const currentModeId = modeIdRef.current if (modeId && modeId !== currentModeId) { @@ -326,7 +330,7 @@ export function useConnectionLifecycle({ console.error("[ConnLifecycle] sendPrompt:", e) ) }, - [connSetMode, sendPrompt, contextKey, touchActivity] + [connSetMode, sendPrompt, contextKey, touchActivity, sharedT] ) const handleCancel = useCallback(() => { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2e21121..d4604ba 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1079,7 +1079,9 @@ "preflightCheckFailedDefault": "Preflight checks failed. Check Agent settings.", "preflightFailedTitle": "{agent} preflight failed", "autoLinkPreflightFailed": "Auto-link preflight failed: {message}", - "connectFailedTitle": "{agent} connection failed" + "connectFailedTitle": "{agent} connection failed", + "toolFallbackTitle": "Tool", + "eventErrorTitle": "Agent Error" }, "connectionLifecycle": { "tasks": { @@ -1092,6 +1094,11 @@ "connectionFailed": "Connection failed" } }, + "shared": { + "attachedResources": "Attached resources", + "toolCallFailed": "Tool call failed", + "planUpdated": "Plan updated" + }, "messageThread": { "emptyTitle": "No messages yet", "emptyDescription": "Start a conversation to see messages here" @@ -1222,6 +1229,38 @@ "subjectLabel": "Subject", "taskLabel": "Task", "nameLabel": "Name:", + "field": { + "file": "File", + "notebook": "Notebook", + "command": "Command", + "old": "Old", + "new": "New", + "pattern": "Pattern", + "path": "Path", + "query": "Query", + "url": "URL", + "description": "Description", + "content": "Content", + "source": "Source", + "prompt": "Prompt", + "subject": "Subject", + "taskId": "Task ID", + "status": "Status", + "skill": "Skill", + "args": "Args", + "offset": "Offset", + "limit": "Limit", + "glob": "Glob", + "type": "Type", + "output": "Output", + "replaceAll": "Replace All", + "language": "Language", + "timeout": "Timeout", + "background": "Background", + "agentType": "Agent Type", + "library": "Library", + "libraryId": "Library ID" + }, "title": { "edit": "Edit", "command": "Command", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4dfc23e..a9f40b6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1079,7 +1079,9 @@ "preflightCheckFailedDefault": "预检查未通过,请检查 Agent 配置。", "preflightFailedTitle": "{agent} 预检查失败", "autoLinkPreflightFailed": "自动链接预检查失败:{message}", - "connectFailedTitle": "{agent} 连接失败" + "connectFailedTitle": "{agent} 连接失败", + "toolFallbackTitle": "工具", + "eventErrorTitle": "Agent 错误" }, "connectionLifecycle": { "tasks": { @@ -1092,6 +1094,11 @@ "connectionFailed": "连接失败" } }, + "shared": { + "attachedResources": "附加资源", + "toolCallFailed": "工具调用失败", + "planUpdated": "计划已更新" + }, "messageThread": { "emptyTitle": "暂无消息", "emptyDescription": "开始一个会话后,消息会显示在这里" @@ -1222,6 +1229,38 @@ "subjectLabel": "主题", "taskLabel": "任务", "nameLabel": "名称:", + "field": { + "file": "文件", + "notebook": "Notebook", + "command": "命令", + "old": "旧内容", + "new": "新内容", + "pattern": "模式", + "path": "路径", + "query": "查询", + "url": "URL", + "description": "描述", + "content": "内容", + "source": "源内容", + "prompt": "提示词", + "subject": "主题", + "taskId": "任务 ID", + "status": "状态", + "skill": "Skill", + "args": "参数", + "offset": "偏移", + "limit": "限制", + "glob": "Glob", + "type": "类型", + "output": "输出", + "replaceAll": "全部替换", + "language": "语言", + "timeout": "超时", + "background": "后台", + "agentType": "Agent 类型", + "library": "库", + "libraryId": "库 ID" + }, "title": { "edit": "编辑", "command": "命令", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 092eb65..53c6429 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1079,7 +1079,9 @@ "preflightCheckFailedDefault": "預檢查未通過,請檢查 Agent 設定。", "preflightFailedTitle": "{agent} 預檢查失敗", "autoLinkPreflightFailed": "自動連結預檢查失敗:{message}", - "connectFailedTitle": "{agent} 連線失敗" + "connectFailedTitle": "{agent} 連線失敗", + "toolFallbackTitle": "工具", + "eventErrorTitle": "Agent 錯誤" }, "connectionLifecycle": { "tasks": { @@ -1092,6 +1094,11 @@ "connectionFailed": "連線失敗" } }, + "shared": { + "attachedResources": "附加資源", + "toolCallFailed": "工具呼叫失敗", + "planUpdated": "計畫已更新" + }, "messageThread": { "emptyTitle": "暫無訊息", "emptyDescription": "開始一個會話後,訊息會顯示在這裡" @@ -1222,6 +1229,38 @@ "subjectLabel": "主題", "taskLabel": "任務", "nameLabel": "名稱:", + "field": { + "file": "檔案", + "notebook": "Notebook", + "command": "命令", + "old": "舊內容", + "new": "新內容", + "pattern": "模式", + "path": "路徑", + "query": "查詢", + "url": "URL", + "description": "描述", + "content": "內容", + "source": "來源內容", + "prompt": "提示詞", + "subject": "主題", + "taskId": "任務 ID", + "status": "狀態", + "skill": "Skill", + "args": "參數", + "offset": "位移", + "limit": "限制", + "glob": "Glob", + "type": "類型", + "output": "輸出", + "replaceAll": "全部替換", + "language": "語言", + "timeout": "逾時", + "background": "背景", + "agentType": "Agent 類型", + "library": "函式庫", + "libraryId": "函式庫 ID" + }, "title": { "edit": "編輯", "command": "命令", diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 664ea54..a41776f 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -60,6 +60,12 @@ export interface AdaptedMessage { model?: string | null } +export interface AdapterMessageText { + attachedResources: string + toolCallFailed: string + planUpdated: string +} + type InlineToolSegment = | { kind: "text"; value: string } | { kind: "tool_call" | "tool_result"; value: string } @@ -271,7 +277,8 @@ function parseInlineToolResultPayload(payload: string): { function expandInlineToolText( text: string, messageId: string, - blockIndex: number + blockIndex: number, + toolCallFailedText: string ): AdaptedContentPart[] | null { const segments = splitInlineToolSegments(text) if (!segments) return null @@ -320,7 +327,7 @@ function expandInlineToolText( output = parsedResult.output if (parsedResult.isError) { state = "output-error" - errorText = output ?? "Tool call failed" + errorText = output ?? toolCallFailedText } index = lookahead } @@ -345,7 +352,7 @@ function expandInlineToolText( toolCallId, output: parsedResult.output, errorText: parsedResult.isError - ? (parsedResult.output ?? "Tool call failed") + ? (parsedResult.output ?? toolCallFailedText) : undefined, state: parsedResult.isError ? "output-error" : "output-available", }) @@ -440,7 +447,10 @@ export function extractUserResourcesFromText(text: string): { } } -function splitUserTextAndResources(parts: AdaptedContentPart[]): { +function splitUserTextAndResources( + parts: AdaptedContentPart[], + attachedResourcesText: string +): { parts: AdaptedContentPart[] resources: UserResourceDisplay[] } { @@ -464,7 +474,7 @@ function splitUserTextAndResources(parts: AdaptedContentPart[]): { } if (nextParts.length === 0 && resources.length > 0) { - nextParts.push({ type: "text", text: "Attached resources" }) + nextParts.push({ type: "text", text: attachedResourcesText }) } return { parts: nextParts, resources } @@ -545,7 +555,10 @@ function buildToolResultMap( * Transform a MessageTurn (from backend) to AdaptedMessage format. * Same correlation logic as adaptUnifiedMessage but operates on turn.blocks. */ -export function adaptMessageTurn(turn: MessageTurn): AdaptedMessage { +export function adaptMessageTurn( + turn: MessageTurn, + text: Pick +): AdaptedMessage { const adaptedContent: AdaptedContentPart[] = [] const resultMap = buildToolResultMap(turn.blocks) const matchedResultIds = new Set() @@ -557,7 +570,12 @@ export function adaptMessageTurn(turn: MessageTurn): AdaptedMessage { const block = turn.blocks[index] if (turn.role === "assistant" && block.type === "text") { - const expandedParts = expandInlineToolText(block.text, turn.id, index) + const expandedParts = expandInlineToolText( + block.text, + turn.id, + index, + text.toolCallFailed + ) if (expandedParts) { adaptedContent.push(...expandedParts) continue @@ -641,7 +659,7 @@ export function adaptMessageTurn(turn: MessageTurn): AdaptedMessage { const userSplit = turn.role === "user" - ? splitUserTextAndResources(adaptedContent) + ? splitUserTextAndResources(adaptedContent, text.attachedResources) : { parts: adaptedContent, resources: [] as UserResourceDisplay[] } return { @@ -661,8 +679,11 @@ export function adaptMessageTurn(turn: MessageTurn): AdaptedMessage { * Transform all turns in a conversation to AdaptedMessage[]. * Internally computes completedToolIds so callers don't need to. */ -export function adaptMessageTurns(turns: MessageTurn[]): AdaptedMessage[] { - return turns.map((turn) => adaptMessageTurn(turn)) +export function adaptMessageTurns( + turns: MessageTurn[], + text: Pick +): AdaptedMessage[] { + return turns.map((turn) => adaptMessageTurn(turn, text)) } /** @@ -942,19 +963,22 @@ function selectLiveToolOutput(params: { } function formatPlanEntries( - entries: Array<{ content: string; priority: string; status: string }> + entries: Array<{ content: string; priority: string; status: string }>, + planUpdatedText: string ): string { if (entries.length === 0) { - return "Plan updated" + return planUpdatedText } const lines = entries.map( (entry) => `- [${entry.status}] ${entry.content} (${entry.priority})` ) - return `Plan updated:\n${lines.join("\n")}` + return `${planUpdatedText}:\n${lines.join("\n")}` } interface AdaptLiveMessageOptions { isLiveStreaming?: boolean + toolCallFailedText: string + planUpdatedText: string } function isReasoningBlock(block: LiveMessage["content"][number]): boolean { @@ -976,7 +1000,7 @@ function findLastReasoningIndex(message: LiveMessage): number { */ export function adaptLiveMessageFromAcp( message: LiveMessage, - options: AdaptLiveMessageOptions = {} + options: AdaptLiveMessageOptions ): AdaptedMessage { const isLiveStreaming = options.isLiveStreaming ?? true const adaptedContent: AdaptedContentPart[] = [] @@ -1034,7 +1058,7 @@ export function adaptLiveMessageFromAcp( output, errorText: state === "output-error" - ? selectedOutput || "Tool call failed" + ? selectedOutput || options.toolCallFailedText : undefined, }) break @@ -1043,7 +1067,7 @@ export function adaptLiveMessageFromAcp( case "plan": adaptedContent.push({ type: "reasoning", - content: formatPlanEntries(block.entries), + content: formatPlanEntries(block.entries, options.planUpdatedText), isStreaming: index === lastStreamingReasoningIndex, }) break diff --git a/src/lib/prompt-draft.ts b/src/lib/prompt-draft.ts index 04161b6..32cd61c 100644 --- a/src/lib/prompt-draft.ts +++ b/src/lib/prompt-draft.ts @@ -10,15 +10,24 @@ function isResourceLinkBlock( return block.type === "resource_link" } -export function getPromptDraftDisplayText(draft: PromptDraft): string { +export function getPromptDraftDisplayText( + draft: PromptDraft, + attachedResourcesFallback: string +): string { const trimmed = draft.displayText.trim() - return trimmed || "Attached resources" + return trimmed || attachedResourcesFallback } export function buildUserMessageTextPartsFromDraft( - draft: PromptDraft + draft: PromptDraft, + attachedResourcesFallback: string ): AdaptedContentPart[] { - return [{ type: "text", text: getPromptDraftDisplayText(draft) }] + return [ + { + type: "text", + text: getPromptDraftDisplayText(draft, attachedResourcesFallback), + }, + ] } export function extractUserResourcesFromDraft( diff --git a/src/lib/types.ts b/src/lib/types.ts index d06617d..4e33000 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,6 +23,8 @@ export type AgentType = export type AppErrorCode = | "unknown" | "invalid_input" + | "configuration_missing" + | "configuration_invalid" | "not_found" | "already_exists" | "permission_denied"