Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add provider-specific terminal button
Add a terminal button next to each provider card that opens a new terminal
window with that provider's specific API configuration. This allows using
different providers independently without changing the global setting.

Changes:
- Backend: Add `open_provider_terminal` command that extracts provider
  config and creates a temporary claude settings file
- Frontend: Add terminal button to provider cards with proper callback
  propagation through component hierarchy
- Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.),
  and Windows (cmd)

Each provider gets a unique config file named `claude_<providerId>_<pid>.json`
in the temp directory, containing the provider's API configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
  • Loading branch information
tianrking and claude committed Dec 23, 2025
commit 3dcbe313bed2085ab64e00f22727d69a28934274
233 changes: 233 additions & 0 deletions src-tauri/src/commands/misc.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#![allow(non_snake_case)]

use crate::app_config::AppType;
use crate::init_status::InitErrorPayload;
use crate::services::ProviderService;
use tauri::AppHandle;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use std::str::FromStr;

#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
Expand Down Expand Up @@ -282,3 +286,232 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {

(None, Some("未安装或无法执行".to_string()))
}

/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
#[allow(non_snake_case)]
#[tauri::command]
pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;

// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
.map_err(|e| format!("获取提供商列表失败: {e}"))?;

let provider = providers.get(&providerId)
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;

// 从提供商配置中提取环境变量
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);

// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;

Ok(true)
}

/// 从提供商配置中提取环境变量
fn extract_env_vars_from_config(
config: &serde_json::Value,
app_type: &AppType,
) -> Vec<(String, String)> {
let mut env_vars = Vec::new();

if let Some(obj) = config.as_object() {
// Claude 使用 env 字段
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
for (key, value) in env {
if let Some(str_val) = value.as_str() {
env_vars.push((key.clone(), str_val.to_string()));
}
}
}

// Codex 使用 auth 字段
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
match app_type {
AppType::Codex => {
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
}
_ => {}
}
}

// Gemini 使用 API_KEY
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
match app_type {
AppType::Gemini => {
env_vars.push(("GOOGLE_API_KEY".to_string(), api_key.to_string()));
Comment thread
tianrking marked this conversation as resolved.
Outdated
}
_ => {}
}
}

// 提取 base_url(如果存在)
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) {
if let Some(url_str) = base_url.as_str() {
match app_type {
AppType::Claude => {
env_vars.push(("ANTHROPIC_BASE_URL".to_string(), url_str.to_string()));
}
AppType::Gemini => {
env_vars.push(("GOOGLE_GEMINI_BASE_URL".to_string(), url_str.to_string()));
}
_ => {}
}
}
}
}
}

env_vars
}

/// 创建临时配置文件并启动 claude 终端
Comment thread
tianrking marked this conversation as resolved.
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(env_vars: Vec<(String, String)>, provider_id: &str) -> Result<(), String> {
use std::process::Command;

// 创建临时配置文件,使用提供商ID和进程ID确保唯一性
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!("claude_{}_{}.json", provider_id, std::process::id()));

// 构建 claude 配置 JSON 格式
let mut config_obj = serde_json::Map::new();
let mut env_obj = serde_json::Map::new();

for (key, value) in &env_vars {
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
}

config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));

let config_json = serde_json::to_string_pretty(&config_obj)
.map_err(|e| format!("序列化配置失败: {e}"))?;

// 写入临时配置文件
std::fs::write(&config_file, config_json)
Comment thread
tianrking marked this conversation as resolved.
Outdated
.map_err(|e| format!("写入配置文件失败: {e}"))?;

// 转义配置文件路径用于 shell
let config_path_escaped = config_file.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace(' ', "\\ ");

#[cfg(target_os = "macos")]
{
// macOS: 使用 Terminal.app 启动 claude
let mut terminal_cmd = Command::new("osascript");
terminal_cmd.arg("-e");

let config_file_for_cleanup = config_file.clone();
let script = format!(
r#"tell application "Terminal"
activate
do script "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; exit"
end tell"#,
config_path_escaped, config_path_escaped
);

terminal_cmd.arg(&script);

terminal_cmd
.spawn()
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;

return Ok(());
}

#[cfg(target_os = "linux")]
{
// Linux: 尝试使用常见终端
let terminals = [
"gnome-terminal", "konsole", "xfce4-terminal",
"mate-terminal", "lxterminal", "alacritty", "kitty",
];

let mut last_error = String::from("未找到可用的终端");

for terminal in terminals {
// 检查终端是否存在
if Command::new("which").arg(terminal).output().is_err() {
continue;
}

let result = Command::new(terminal)
.arg("--")
.arg("sh")
.arg("-c")
.arg(&format!(
"echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; $SHELL",
config_path_escaped, config_path_escaped
))
.spawn();

match result {
Ok(_) => {
return Ok(());
}
Err(e) => {
last_error = format!("启动 {} 失败: {}", terminal, e);
continue;
}
}
}

// 如果所有终端都失败,清理配置文件
let _ = std::fs::remove_file(&config_file);
return Err(last_error);
}

#[cfg(target_os = "windows")]
{
use std::io::Write;

// Windows: 创建临时批处理文件
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));

let mut content = String::from("@echo off\n");
content.push_str(&format!("echo Using provider-specific claude config:\n"));
content.push_str(&format!("echo {}\n", config_file.to_string_lossy().to_string().replace('&', "^&")));
content.push_str(&format!("claude --settings \"{}\"\n", config_file.to_string_lossy().to_string().replace('&', "^&")));
content.push_str("if errorlevel 1 (\n");
content.push_str(" echo.\n");
content.push_str(" echo Press any key to close...\n");
content.push_str(" pause >nul\n");
content.push_str(")\n");

std::fs::write(&bat_file, content)
.map_err(|e| format!("写入批处理文件失败: {e}"))?;

// 启动新的 cmd 窗口执行批处理文件
Command::new("cmd")
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy().to_string()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;

return Ok(());
}

// 这个代码在所有支持的平台上都不可达,因为前面的平台特定块都已经返回了
// 使用 cfg 和 allow 来避免编译器警告和错误
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[allow(unreachable_code)]
{
Ok(())
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("不支持的操作系统".to_string())
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mod tray;
mod usage_script;

pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use commands::open_provider_terminal;
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
Expand Down Expand Up @@ -689,6 +690,8 @@ pub fn run() {
commands::get_stream_check_config,
commands::save_stream_check_config,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
]);

let app = builder
Expand Down
21 changes: 21 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,26 @@ function App() {
await addProvider(duplicatedProvider);
};

// 打开提供商终端
const handleOpenTerminal = async (provider: Provider) => {
try {
await providersApi.openTerminal(provider.id, activeApp);
toast.success(
t("provider.terminalOpened", {
defaultValue: "终端已打开",
}),
);
} catch (error) {
console.error("[App] Failed to open terminal", error);
const errorMessage = extractErrorMessage(error);
toast.error(
t("provider.terminalOpenFailed", {
defaultValue: "打开终端失败",
}) + (errorMessage ? `: ${errorMessage}` : ""),
);
}
};

// 导入配置成功后刷新
const handleImportSuccess = async () => {
try {
Expand Down Expand Up @@ -378,6 +398,7 @@ function App() {
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onOpenTerminal={handleOpenTerminal}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
Expand Down
18 changes: 18 additions & 0 deletions src/components/providers/ProviderActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Loader2,
Play,
Plus,
Terminal,
TestTube2,
Trash2,
} from "lucide-react";
Expand All @@ -23,6 +24,7 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onOpenTerminal?: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
Expand All @@ -39,6 +41,7 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onOpenTerminal,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
Expand Down Expand Up @@ -171,6 +174,21 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</Button>

{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}

<Button
size="icon"
variant="ghost"
Expand Down
3 changes: 3 additions & 0 deletions src/components/providers/ProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
Expand Down Expand Up @@ -91,6 +92,7 @@ export function ProviderCard({
onOpenWebsite,
onDuplicate,
onTest,
onOpenTerminal,
isTesting,
isProxyRunning,
isProxyTakeover = false,
Expand Down Expand Up @@ -339,6 +341,7 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
Expand Down
Loading