Compare commits

..

5 Commits

Author SHA1 Message Date
YoVinchen 907734c542 fix(terminal): escape paths for PowerShell and cmd.exe in Windows launcher 2026-03-31 15:41:09 +08:00
YoVinchen 9ab54c93ef fix(terminal): use pushd for UNC paths in Windows batch launcher
`cmd.exe` cannot set a UNC path (e.g. `\\wsl$\...`) as the current
directory via `cd /d`; it errors with "CMD does not support UNC paths
as current directories". Switch to `pushd` which temporarily maps the
UNC share to a drive letter.

Rename `build_windows_cd_command` → `build_windows_cwd_command` to
reflect the broader semantics. Extract `build_windows_cwd_command_str`
and `is_windows_unc_path` helpers for testability, and add unit tests
covering drive paths, UNC paths, and batch metacharacter escaping.

Also fix minor style issues: sort mod declarations alphabetically,
add missing EOF newline in lightweight.rs, add explicit type annotation
in streaming_responses test, and reformat tray menu builder chain.
2026-03-30 23:21:27 +08:00
YoVinchen f5320dbdd2 fix(terminal): restore UNC paths when stripping Windows verbatim prefix
Handle \\?\UNC\server\share form separately from regular \\?\ prefix,
converting it back to \\server\share so network/WSL directory paths
remain valid in batch cd commands.
2026-03-30 10:24:48 +08:00
YoVinchen 9701de354f fix(terminal): preserve cwd path and strip Windows verbatim prefix
- Stop trimming non-empty paths so directories with leading/trailing
  spaces on Unix are handled correctly
- Strip \\?\ extended-length prefix from canonicalized paths on Windows
  to prevent batch script cd failures
2026-03-30 09:45:42 +08:00
YoVinchen 02fd639dfc Add directory picker before launching Claude terminal 2026-03-30 09:04:51 +08:00
13 changed files with 290 additions and 148 deletions
+212 -15
View File
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
@@ -720,8 +720,10 @@ pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
cwd: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
let launch_cwd = resolve_launch_cwd(cwd)?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
@@ -736,7 +738,8 @@ pub async fn open_provider_terminal(
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
.map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
@@ -791,11 +794,49 @@ fn extract_env_vars_from_config(
env_vars
}
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
return Ok(None);
};
if raw_path.contains('\n') || raw_path.contains('\r') {
return Err("目录路径包含非法换行符".to_string());
}
let path = Path::new(&raw_path);
if !path.exists() {
return Err(format!("目录不存在: {raw_path}"));
}
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
if !resolved.is_dir() {
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
}
// Strip Windows extended-length prefix that canonicalize produces,
// as it can break batch scripts and other shell commands.
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
#[cfg(target_os = "windows")]
let resolved = {
let s = resolved.to_string_lossy();
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{unc}"))
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
resolved
}
};
Ok(Some(resolved))
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
cwd: Option<&Path>,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
@@ -809,19 +850,19 @@ fn launch_terminal_with_env(
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file)?;
launch_macos_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file)?;
launch_linux_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
}
@@ -851,7 +892,7 @@ fn write_claude_config(
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
@@ -860,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
// Write the shell script to a temp file
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
script_file = script_file.display(),
cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1007,7 +1051,7 @@ fn launch_macos_open_app(
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
@@ -1029,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
script_file = script_file.display(),
cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1118,28 +1165,35 @@ fn which_command(cmd: &str) -> bool {
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
cwd: Option<&Path>,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
let cwd_command = build_windows_cwd_command(cwd);
let content = format!(
"@echo off
{cwd_command}
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
config_path_for_batch, config_path_for_batch, config_path_for_batch
config_path_for_batch,
config_path_for_batch,
config_path_for_batch,
cwd_command = cwd_command,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
let bat_path_for_cmd = build_windows_cmd_command_str(&bat_path);
let ps_cmd = format!("& '{}'", escape_powershell_single_quoted(&bat_path));
// Try the preferred terminal first
let result = match terminal {
@@ -1147,8 +1201,10 @@ del \"%~f0\" >nul 2>&1
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
"wt" => {
run_windows_start_command(&["wt", "cmd", "/K", &bat_path_for_cmd], "Windows Terminal")
}
_ => run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "cmd"), // "cmd" or default
};
// If preferred terminal fails and it's not the default, try cmd as fallback
@@ -1158,12 +1214,81 @@ del \"%~f0\" >nul 2>&1
terminal,
result.as_ref().err()
);
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
return run_windows_start_command(&["cmd", "/K", &bat_path_for_cmd], "cmd");
}
result
}
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| {
format!(
"cd {} || exit 1\n",
shell_single_quote(&dir.to_string_lossy())
)
})
.unwrap_or_default()
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_powershell_single_quoted(value: &str) -> String {
value.replace('\'', "''")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cmd_command_str(path: &str) -> String {
// Avoid handing `cmd /K` a string that starts with a single quoted path:
// per cmd.exe parsing rules, those outer quotes may be stripped when the
// quoted text contains shell metacharacters. An explicit `call "..."` form
// keeps the command from starting with a quote while still protecting
// spaces and other special characters in the batch path.
format!("call \"{}\"", escape_windows_cmd_quoted_path(path))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_cmd_quoted_path(value: &str) -> String {
value.replace('%', "%%")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn is_windows_unc_path(path: &str) -> bool {
path.starts_with(r"\\")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cwd_command_str(path: &str) -> String {
let escaped = escape_windows_batch_value(path);
if is_windows_unc_path(path) {
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
format!("pushd \"{escaped}\" || exit /b 1\r\n")
} else {
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
}
}
#[cfg(target_os = "windows")]
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
.unwrap_or_default()
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_batch_value(value: &str) -> String {
value
.replace('^', "^^")
.replace('%', "%%")
.replace('&', "^&")
.replace('|', "^|")
.replace('<', "^<")
.replace('>', "^>")
.replace('(', "^(")
.replace(')', "^)")
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
@@ -1338,4 +1463,76 @@ mod tests {
]
);
}
#[test]
fn resolve_launch_cwd_accepts_existing_directory() {
let resolved =
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
.expect("temp dir should resolve")
.expect("temp dir should be present");
assert!(resolved.is_dir());
}
#[test]
fn resolve_launch_cwd_rejects_missing_directory() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
.expect_err("missing directory should fail");
assert!(error.contains("目录不存在"));
}
#[test]
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[test]
fn escape_powershell_single_quoted_doubles_embedded_quotes() {
let escaped = escape_powershell_single_quoted(r"C:\Users\O'Brien\AppData\Local\Temp");
assert_eq!(escaped, r"C:\Users\O''Brien\AppData\Local\Temp");
}
#[test]
fn build_windows_cmd_command_str_quotes_and_escapes_metacharacters() {
let command = build_windows_cmd_command_str(r"C:\Users\100%&(test)\cc switch.bat");
assert_eq!(command, "call \"C:\\Users\\100%%&(test)\\cc switch.bat\"");
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
}
#[test]
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
assert_eq!(
command,
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
);
}
#[test]
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
assert_eq!(
command,
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
);
}
}
+19 -43
View File
@@ -6,32 +6,6 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
plugin_name == prefix
|| plugin_name
.strip_prefix(prefix)
.map(|suffix| suffix.starts_with('@'))
.unwrap_or(false)
}
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
prefixes
.iter()
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
}
fn canonicalize_plugin_name(plugin_name: &str) -> String {
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
if suffix.is_empty() || suffix.starts_with('@') {
return format!("oh-my-openagent{suffix}");
}
}
plugin_name.to_string()
}
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
@@ -166,56 +140,58 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
// Adding standard OMO -> remove all Slim variants
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
{
} else if plugin_name.starts_with("oh-my-opencode-slim") {
// Adding Slim -> remove all standard OMO variants (but keep slim)
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
})
.unwrap_or(true)
});
}
let already_exists = arr
.iter()
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
if !already_exists {
arr.push(Value::String(normalized_plugin_name));
arr.push(Value::String(plugin_name.to_string()));
}
}
None => {
config["plugin"] = json!([normalized_plugin_name]);
config["plugin"] = json!([plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| !matches_any_plugin_prefix(s, prefixes))
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.unwrap_or(true)
});
+24 -77
View File
@@ -21,41 +21,33 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
pub preferred_filename: &'static str,
pub config_candidates: &'static [&'static str],
pub filename: &'static str,
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefixes: &'static [&'static str],
pub plugin_prefix: &'static str,
pub has_categories: bool,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
preferred_filename: "oh-my-openagent.jsonc",
config_candidates: &[
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
],
filename: "oh-my-opencode.jsonc",
category: "omo",
provider_prefix: "omo-",
plugin_name: "oh-my-openagent@latest",
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
has_categories: true,
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
preferred_filename: "oh-my-opencode-slim.jsonc",
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
filename: "oh-my-opencode-slim.jsonc",
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefixes: &["oh-my-opencode-slim"],
plugin_prefix: "oh-my-opencode-slim",
has_categories: false,
label: "OMO Slim",
import_label: "Imported Slim",
@@ -68,27 +60,22 @@ pub struct OmoService;
impl OmoService {
// ── Path helpers ────────────────────────────────────────
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
v.config_candidates
.iter()
.map(|name| base_dir.join(name))
.collect()
}
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
Self::config_candidates(v, base_dir)
.into_iter()
.find(|path| path.exists())
}
fn config_path(v: &OmoVariant) -> PathBuf {
let base_dir = get_opencode_dir();
Self::find_existing_config_path(v, &base_dir)
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
get_opencode_dir().join(v.filename)
}
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
let config_path = Self::config_path(v);
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
@@ -136,18 +123,12 @@ impl OmoService {
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let base_dir = get_opencode_dir();
let mut deleted_paths = Vec::new();
for config_path in Self::config_candidates(v, &base_dir) {
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
deleted_paths.push(config_path);
}
let config_path = Self::config_path(v);
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("{} config file deleted: {config_path:?}", v.label);
}
if !deleted_paths.is_empty() {
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
}
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
Ok(())
}
@@ -470,38 +451,4 @@ mod tests {
assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents"));
}
#[test]
fn test_find_existing_config_prefers_new_name_over_old() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
let new_path = dir.path().join("oh-my-openagent.jsonc");
// Create both old and new files
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
new_path,
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
);
}
#[test]
fn test_find_existing_config_falls_back_to_old_name() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
// Only old file exists
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
old_path,
"When only the old config file exists, it should still be found"
);
}
}
+2 -4
View File
@@ -471,10 +471,8 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式失败: {e}");
}
} else {
if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
log::error!("进入轻量模式失败: {e}");
}
} else if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
log::error!("进入轻量模式失败: {e}");
}
}
"quit" => {
+8 -1
View File
@@ -648,7 +648,14 @@ function App() {
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
const selectedDir = await settingsApi.pickDirectory();
if (!selectedDir) {
return;
}
await providersApi.openTerminal(provider.id, activeApp, {
cwd: selectedDir,
});
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
@@ -65,7 +65,7 @@ export function ProviderPresetSelector({
case "omo":
return t("providerForm.omoHint", {
defaultValue:
"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
});
default:
return t("providerPreset.hint", {
+1 -1
View File
@@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "Oh My OpenCode",
websiteUrl: "https://github.com/code-yeongyu/oh-my-openagent",
websiteUrl: "https://github.com/code-yeongyu/oh-my-opencode",
settingsConfig: {
npm: "",
options: {},
+1 -1
View File
@@ -698,7 +698,7 @@
"aggregatorApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"thirdPartyApiKeyHint": "💡 Only need to fill in API Key, endpoint is preset",
"customApiKeyHint": "💡 Custom configuration requires manually filling all necessary fields",
"omoHint": "💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc",
"omoHint": "💡 OMO config manages Agent model assignments and writes to oh-my-opencode.jsonc",
"officialHint": "💡 Official provider uses browser login, no API Key needed",
"getApiKey": "Get API Key",
"partnerPromotion": {
+1 -1
View File
@@ -698,7 +698,7 @@
"aggregatorApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"thirdPartyApiKeyHint": "💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです",
"customApiKeyHint": "💡 カスタム設定では必要な項目をすべて手動で入力してください",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します",
"omoHint": "💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-opencode.jsonc に書き込みます",
"officialHint": "💡 公式プロバイダーはブラウザログインで、API Key は不要です",
"getApiKey": "API Key を取得",
"partnerPromotion": {
+1 -1
View File
@@ -698,7 +698,7 @@
"aggregatorApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"thirdPartyApiKeyHint": "💡 只需填写 API Key,请求地址已预设",
"customApiKeyHint": "💡 自定义配置需手动填写所有必要字段",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc",
"omoHint": "💡 OMO 配置管理 Agent 模型分配,写入 oh-my-opencode.jsonc",
"officialHint": "💡 官方供应商使用浏览器登录,无需配置 API Key",
"getApiKey": "获取 API Key",
"partnerPromotion": {
+15 -2
View File
@@ -21,6 +21,10 @@ export interface SwitchResult {
warnings: string[];
}
export interface OpenTerminalOptions {
cwd?: string;
}
export const providersApi = {
async getAll(appId: AppId): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app: appId });
@@ -83,8 +87,17 @@ export const providersApi = {
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
* 终端会使用该提供商特定的 API 配置,不影响全局设置
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
async openTerminal(
providerId: string,
appId: AppId,
options?: OpenTerminalOptions,
): Promise<boolean> {
const { cwd } = options ?? {};
return await invoke("open_provider_terminal", {
providerId,
app: appId,
cwd,
});
},
/**
+4
View File
@@ -47,6 +47,10 @@ export const settingsApi = {
await invoke("open_config_folder", { app: appId });
},
async pickDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath });
},
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { defaultPath });
},
+1 -1
View File
@@ -246,7 +246,7 @@ export const OMO_DISABLEABLE_SKILLS = [
] as const;
export const OMO_DEFAULT_SCHEMA_URL =
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json";
export const OMO_SISYPHUS_AGENT_PLACEHOLDER = `{
"disabled": false,