Skip to content

Commit 902dff1

Browse files
tianrkingclaude
andcommitted
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>
1 parent 1586451 commit 902dff1

7 files changed

Lines changed: 292 additions & 0 deletions

File tree

src-tauri/src/commands/misc.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
#![allow(non_snake_case)]
22

3+
use crate::app_config::AppType;
34
use crate::init_status::InitErrorPayload;
5+
use crate::services::ProviderService;
46
use tauri::AppHandle;
7+
use tauri::State;
58
use tauri_plugin_opener::OpenerExt;
9+
use std::str::FromStr;
610

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

283287
(None, Some("未安装或无法执行".to_string()))
284288
}
289+
290+
/// 打开指定提供商的终端
291+
///
292+
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
293+
/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端
294+
#[allow(non_snake_case)]
295+
#[tauri::command]
296+
pub async fn open_provider_terminal(
297+
state: State<'_, crate::store::AppState>,
298+
app: String,
299+
#[allow(non_snake_case)] providerId: String,
300+
) -> Result<bool, String> {
301+
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
302+
303+
// 获取提供商配置
304+
let providers = ProviderService::list(state.inner(), app_type.clone())
305+
.map_err(|e| format!("获取提供商列表失败: {e}"))?;
306+
307+
let provider = providers.get(&providerId)
308+
.ok_or_else(|| format!("提供商 {providerId} 不存在"))?;
309+
310+
// 从提供商配置中提取环境变量
311+
let config = &provider.settings_config;
312+
let env_vars = extract_env_vars_from_config(config, &app_type);
313+
314+
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
315+
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
316+
317+
Ok(true)
318+
}
319+
320+
/// 从提供商配置中提取环境变量
321+
fn extract_env_vars_from_config(
322+
config: &serde_json::Value,
323+
app_type: &AppType,
324+
) -> Vec<(String, String)> {
325+
let mut env_vars = Vec::new();
326+
327+
if let Some(obj) = config.as_object() {
328+
// Claude 使用 env 字段
329+
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
330+
for (key, value) in env {
331+
if let Some(str_val) = value.as_str() {
332+
env_vars.push((key.clone(), str_val.to_string()));
333+
}
334+
}
335+
}
336+
337+
// Codex 使用 auth 字段
338+
if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) {
339+
match app_type {
340+
AppType::Codex => {
341+
env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string()));
342+
}
343+
_ => {}
344+
}
345+
}
346+
347+
// Gemini 使用 API_KEY
348+
if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) {
349+
match app_type {
350+
AppType::Gemini => {
351+
env_vars.push(("GOOGLE_API_KEY".to_string(), api_key.to_string()));
352+
}
353+
_ => {}
354+
}
355+
}
356+
357+
// 提取 base_url(如果存在)
358+
if let Some(env) = obj.get("env").and_then(|v| v.as_object()) {
359+
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) {
360+
if let Some(url_str) = base_url.as_str() {
361+
match app_type {
362+
AppType::Claude => {
363+
env_vars.push(("ANTHROPIC_BASE_URL".to_string(), url_str.to_string()));
364+
}
365+
AppType::Gemini => {
366+
env_vars.push(("GOOGLE_GEMINI_BASE_URL".to_string(), url_str.to_string()));
367+
}
368+
_ => {}
369+
}
370+
}
371+
}
372+
}
373+
}
374+
375+
env_vars
376+
}
377+
378+
/// 创建临时配置文件并启动 claude 终端
379+
/// 使用 --settings 参数传入提供商特定的 API 配置
380+
fn launch_terminal_with_env(env_vars: Vec<(String, String)>, provider_id: &str) -> Result<(), String> {
381+
use std::process::Command;
382+
383+
// 创建临时配置文件,使用提供商ID和进程ID确保唯一性
384+
let temp_dir = std::env::temp_dir();
385+
let config_file = temp_dir.join(format!("claude_{}_{}.json", provider_id, std::process::id()));
386+
387+
// 构建 claude 配置 JSON 格式
388+
let mut config_obj = serde_json::Map::new();
389+
let mut env_obj = serde_json::Map::new();
390+
391+
for (key, value) in &env_vars {
392+
env_obj.insert(key.clone(), serde_json::Value::String(value.clone()));
393+
}
394+
395+
config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj));
396+
397+
let config_json = serde_json::to_string_pretty(&config_obj)
398+
.map_err(|e| format!("序列化配置失败: {e}"))?;
399+
400+
// 写入临时配置文件
401+
std::fs::write(&config_file, config_json)
402+
.map_err(|e| format!("写入配置文件失败: {e}"))?;
403+
404+
// 转义配置文件路径用于 shell
405+
let config_path_escaped = config_file.to_string_lossy()
406+
.replace('\\', "\\\\")
407+
.replace('"', "\\\"")
408+
.replace('$', "\\$")
409+
.replace(' ', "\\ ");
410+
411+
#[cfg(target_os = "macos")]
412+
{
413+
// macOS: 使用 Terminal.app 启动 claude
414+
let mut terminal_cmd = Command::new("osascript");
415+
terminal_cmd.arg("-e");
416+
417+
let config_file_for_cleanup = config_file.clone();
418+
let script = format!(
419+
r#"tell application "Terminal"
420+
activate
421+
do script "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; exit"
422+
end tell"#,
423+
config_path_escaped, config_path_escaped
424+
);
425+
426+
terminal_cmd.arg(&script);
427+
428+
terminal_cmd
429+
.spawn()
430+
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
431+
432+
return Ok(());
433+
}
434+
435+
#[cfg(target_os = "linux")]
436+
{
437+
// Linux: 尝试使用常见终端
438+
let terminals = [
439+
"gnome-terminal", "konsole", "xfce4-terminal",
440+
"mate-terminal", "lxterminal", "alacritty", "kitty",
441+
];
442+
443+
let mut last_error = String::from("未找到可用的终端");
444+
445+
for terminal in terminals {
446+
// 检查终端是否存在
447+
if Command::new("which").arg(terminal).output().is_err() {
448+
continue;
449+
}
450+
451+
let result = Command::new(terminal)
452+
.arg("--")
453+
.arg("sh")
454+
.arg("-c")
455+
.arg(&format!(
456+
"echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; $SHELL",
457+
config_path_escaped, config_path_escaped
458+
))
459+
.spawn();
460+
461+
match result {
462+
Ok(_) => {
463+
return Ok(());
464+
}
465+
Err(e) => {
466+
last_error = format!("启动 {} 失败: {}", terminal, e);
467+
continue;
468+
}
469+
}
470+
}
471+
472+
// 如果所有终端都失败,清理配置文件
473+
let _ = std::fs::remove_file(&config_file);
474+
return Err(last_error);
475+
}
476+
477+
#[cfg(target_os = "windows")]
478+
{
479+
use std::io::Write;
480+
481+
// Windows: 创建临时批处理文件
482+
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
483+
484+
let mut content = String::from("@echo off\n");
485+
content.push_str(&format!("echo Using provider-specific claude config:\n"));
486+
content.push_str(&format!("echo {}\n", config_file.to_string_lossy().to_string().replace('&', "^&")));
487+
content.push_str(&format!("claude --settings \"{}\"\n", config_file.to_string_lossy().to_string().replace('&', "^&")));
488+
content.push_str("if errorlevel 1 (\n");
489+
content.push_str(" echo.\n");
490+
content.push_str(" echo Press any key to close...\n");
491+
content.push_str(" pause >nul\n");
492+
content.push_str(")\n");
493+
494+
std::fs::write(&bat_file, content)
495+
.map_err(|e| format!("写入批处理文件失败: {e}"))?;
496+
497+
// 启动新的 cmd 窗口执行批处理文件
498+
Command::new("cmd")
499+
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy().to_string()])
500+
.creation_flags(CREATE_NO_WINDOW)
501+
.spawn()
502+
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
503+
504+
return Ok(());
505+
}
506+
507+
// 这个代码在所有支持的平台上都不可达,因为前面的平台特定块都已经返回了
508+
// 使用 cfg 和 allow 来避免编译器警告和错误
509+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
510+
#[allow(unreachable_code)]
511+
{
512+
Ok(())
513+
}
514+
515+
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
516+
Err("不支持的操作系统".to_string())
517+
}

src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod tray;
2525
mod usage_script;
2626

2727
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
28+
pub use commands::open_provider_terminal;
2829
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
2930
pub use commands::*;
3031
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
@@ -689,6 +690,8 @@ pub fn run() {
689690
commands::get_stream_check_config,
690691
commands::save_stream_check_config,
691692
commands::get_tool_versions,
693+
// Provider terminal
694+
commands::open_provider_terminal,
692695
]);
693696

694697
let app = builder

src/App.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,26 @@ function App() {
286286
await addProvider(duplicatedProvider);
287287
};
288288

289+
// 打开提供商终端
290+
const handleOpenTerminal = async (provider: Provider) => {
291+
try {
292+
await providersApi.openTerminal(provider.id, activeApp);
293+
toast.success(
294+
t("provider.terminalOpened", {
295+
defaultValue: "终端已打开",
296+
}),
297+
);
298+
} catch (error) {
299+
console.error("[App] Failed to open terminal", error);
300+
const errorMessage = extractErrorMessage(error);
301+
toast.error(
302+
t("provider.terminalOpenFailed", {
303+
defaultValue: "打开终端失败",
304+
}) + (errorMessage ? `: ${errorMessage}` : ""),
305+
);
306+
}
307+
};
308+
289309
// 导入配置成功后刷新
290310
const handleImportSuccess = async () => {
291311
try {
@@ -378,6 +398,7 @@ function App() {
378398
onDuplicate={handleDuplicateProvider}
379399
onConfigureUsage={setUsageProvider}
380400
onOpenWebsite={handleOpenWebsite}
401+
onOpenTerminal={handleOpenTerminal}
381402
onCreate={() => setIsAddOpen(true)}
382403
/>
383404
</motion.div>

src/components/providers/ProviderActions.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Loader2,
77
Play,
88
Plus,
9+
Terminal,
910
TestTube2,
1011
Trash2,
1112
} from "lucide-react";
@@ -23,6 +24,7 @@ interface ProviderActionsProps {
2324
onTest?: () => void;
2425
onConfigureUsage: () => void;
2526
onDelete: () => void;
27+
onOpenTerminal?: () => void;
2628
// 故障转移相关
2729
isAutoFailoverEnabled?: boolean;
2830
isInFailoverQueue?: boolean;
@@ -39,6 +41,7 @@ export function ProviderActions({
3941
onTest,
4042
onConfigureUsage,
4143
onDelete,
44+
onOpenTerminal,
4245
// 故障转移相关
4346
isAutoFailoverEnabled = false,
4447
isInFailoverQueue = false,
@@ -171,6 +174,21 @@ export function ProviderActions({
171174
<BarChart3 className="h-4 w-4" />
172175
</Button>
173176

177+
{onOpenTerminal && (
178+
<Button
179+
size="icon"
180+
variant="ghost"
181+
onClick={onOpenTerminal}
182+
title={t("provider.openTerminal", "打开终端")}
183+
className={cn(
184+
iconButtonClass,
185+
"hover:text-emerald-600 dark:hover:text-emerald-400",
186+
)}
187+
>
188+
<Terminal className="h-4 w-4" />
189+
</Button>
190+
)}
191+
174192
<Button
175193
size="icon"
176194
variant="ghost"

src/components/providers/ProviderCard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ProviderCardProps {
3333
onOpenWebsite: (url: string) => void;
3434
onDuplicate: (provider: Provider) => void;
3535
onTest?: (provider: Provider) => void;
36+
onOpenTerminal?: (provider: Provider) => void;
3637
isTesting?: boolean;
3738
isProxyRunning: boolean;
3839
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -91,6 +92,7 @@ export function ProviderCard({
9192
onOpenWebsite,
9293
onDuplicate,
9394
onTest,
95+
onOpenTerminal,
9496
isTesting,
9597
isProxyRunning,
9698
isProxyTakeover = false,
@@ -339,6 +341,7 @@ export function ProviderCard({
339341
onTest={onTest ? () => onTest(provider) : undefined}
340342
onConfigureUsage={() => onConfigureUsage(provider)}
341343
onDelete={() => onDelete(provider)}
344+
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
342345
// 故障转移相关
343346
isAutoFailoverEnabled={isAutoFailoverEnabled}
344347
isInFailoverQueue={isInFailoverQueue}

0 commit comments

Comments
 (0)