Reduce Settings complexity by removing the embedded shell surface

The Settings terminal added a full xterm plus Tauri PTY stack for a job
that is better handled by dedicated install and configuration flows. This
change removes the Settings tab, frontend terminal wiring, Tauri terminal
commands, and the terminal-only dependencies so the desktop settings
surface stays narrower and less fragile.

Constraint: The worktree already contains unrelated desktop icon and UI changes, so this commit stages only the terminal-removal slice
Rejected: Keep a hidden or runtime-only terminal stub | it would still preserve the heavy cross-layer maintenance surface
Rejected: Remove only the Settings tab and leave the Tauri PTY backend | that would leave dead code and unused dependencies behind
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If future install workflows need more power, prefer Settings-native actions and runtime refresh over reintroducing a general shell tab
Tested: bun x vitest run src/__tests__/generalSettings.test.tsx src/__tests__/skillsSettings.test.tsx src/__tests__/pluginsSettings.test.tsx src/__tests__/agentsSettings.test.tsx src/__tests__/mcpSettings.test.tsx src/components/settings/InstallCenter.test.tsx; bun run lint; bun run build; cargo check --manifest-path desktop/src-tauri/Cargo.toml
Not-tested: Manual packaged desktop app click-through after removing the Settings terminal tab
This commit is contained in:
程序员阿江(Relakkes) 2026-04-23 11:06:32 +08:00
parent 376e255b6b
commit 48da5ccbfa
17 changed files with 28 additions and 1336 deletions

View File

@ -7,8 +7,6 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",
@ -472,10 +470,6 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],

View File

@ -18,8 +18,6 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dompurify": "^3.3.3",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",

View File

@ -309,12 +309,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "chrono"
version = "0.4.44"
@ -332,7 +326,6 @@ name = "claude-code-desktop"
version = "0.1.5"
dependencies = [
"anyhow",
"portable-pty",
"serde",
"serde_json",
"tauri",
@ -683,12 +676,6 @@ dependencies = [
"tendril 0.5.0",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@ -736,7 +723,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg 0.55.0",
"winreg",
]
[[package]]
@ -806,17 +793,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.27"
@ -1796,12 +1772,6 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@ -2040,18 +2010,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@ -2576,27 +2534,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -3355,17 +3292,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serial2"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@ -3429,22 +3355,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@ -5333,15 +5243,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"

View File

@ -18,5 +18,4 @@ tauri-plugin-process = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-pty = "0.9.0"
anyhow = "1.0.102"

View File

@ -16,13 +16,6 @@ use tauri_plugin_shell::{
ShellExt,
};
mod terminal;
use terminal::{
terminal_close, terminal_resize, terminal_start_session, terminal_write, stop_all_sessions,
TerminalState,
};
#[derive(Default)]
struct ServerState(Mutex<ServerStatus>);
@ -121,8 +114,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
// 我们直接用当前可执行文件所在目录作为 app_root
// Dev: desktop/src-tauri/target/<profile>/ rust 跑出来的 binary 那一层)
// Prod: <App>.app/Contents/MacOS/ sidecar 二进制的同级目录)
let exe = std::env::current_exe()
.map_err(|err| format!("resolve current exe path: {err}"))?;
let exe = std::env::current_exe().map_err(|err| format!("resolve current exe path: {err}"))?;
let dir = exe
.parent()
.ok_or_else(|| "current exe has no parent dir".to_string())?
@ -305,26 +297,18 @@ pub fn run() {
let builder = tauri::Builder::default()
.manage(ServerState::default())
.manage(AdapterState::default())
.manage(TerminalState::default())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_server_url,
restart_adapters_sidecar,
terminal_start_session,
terminal_write,
terminal_resize,
terminal_close
]);
.invoke_handler(tauri::generate_handler![get_server_url, restart_adapters_sidecar]);
// macOS: native menu bar (traffic-light overlay style)
#[cfg(target_os = "macos")]
let builder = builder
.menu(|app| {
let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha")
.build(app)?;
let about_item =
MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha").build(app)?;
let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...")
.accelerator("CmdOrCtrl+,")
.build(app)?;
@ -353,9 +337,7 @@ pub fn run() {
.select_all()
.build()?;
let view_submenu = SubmenuBuilder::new(app, "View")
.fullscreen()
.build()?;
let view_submenu = SubmenuBuilder::new(app, "View").fullscreen().build()?;
let window_submenu = SubmenuBuilder::new(app, "Window")
.minimize()
@ -415,9 +397,6 @@ pub fn run() {
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
stop_server_sidecar(app_handle);
stop_adapters_sidecar(app_handle);
if let Some(state) = app_handle.try_state::<TerminalState>() {
stop_all_sessions(&state);
}
}
});
}

View File

@ -1,522 +0,0 @@
use std::{
collections::HashMap,
fs,
io::{Read, Write},
path::{Path, PathBuf},
sync::Mutex,
thread,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{anyhow, Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
const TERMINAL_EVENT: &str = "desktop-terminal-event";
pub struct TerminalSession {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send>,
cleanup_paths: Vec<PathBuf>,
}
#[derive(Default)]
pub struct TerminalState(pub Mutex<HashMap<String, TerminalSession>>);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminalStartResponse {
pub session_id: String,
pub shell: String,
pub cwd: String,
pub explicit_command_name: String,
pub docs_command_name: String,
}
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TerminalEventPayload {
Output { session_id: String, data: String },
Exit { session_id: String, exit_code: Option<i32> },
}
#[tauri::command]
pub fn terminal_start_session(
app: AppHandle,
state: State<'_, TerminalState>,
cwd: Option<String>,
) -> Result<TerminalStartResponse, String> {
start_session(&app, &state, cwd).map_err(|err| err.to_string())
}
#[tauri::command]
pub fn terminal_write(
state: State<'_, TerminalState>,
session_id: String,
data: String,
) -> Result<(), String> {
let mut guard = state
.0
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
let session = guard
.get_mut(&session_id)
.ok_or_else(|| format!("terminal session not found: {session_id}"))?;
session
.writer
.write_all(data.as_bytes())
.and_then(|_| session.writer.flush())
.map_err(|err| format!("write terminal input: {err}"))
}
#[tauri::command]
pub fn terminal_resize(
state: State<'_, TerminalState>,
session_id: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
if cols == 0 || rows == 0 {
return Ok(());
}
let mut guard = state
.0
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
let session = guard
.get_mut(&session_id)
.ok_or_else(|| format!("terminal session not found: {session_id}"))?;
session
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|err| format!("resize terminal: {err}"))
}
#[tauri::command]
pub fn terminal_close(
state: State<'_, TerminalState>,
session_id: String,
) -> Result<(), String> {
let session = {
let mut guard = state
.0
.lock()
.map_err(|_| "terminal state is unavailable".to_string())?;
guard.remove(&session_id)
};
if let Some(session) = session {
thread::spawn(move || terminate_terminal_session(session));
}
Ok(())
}
pub fn stop_all_sessions(state: &State<'_, TerminalState>) {
let sessions = if let Ok(mut guard) = state.0.lock() {
guard.drain().map(|(_, session)| session).collect::<Vec<_>>()
} else {
Vec::new()
};
for session in sessions {
terminate_terminal_session(session);
}
}
fn cleanup_terminal_session(session: &mut TerminalSession) {
for path in session.cleanup_paths.drain(..) {
let _ = fs::remove_file(path);
}
}
fn terminate_terminal_session(mut session: TerminalSession) {
let _ = session.child.kill();
let _ = session.child.wait();
cleanup_terminal_session(&mut session);
}
fn start_session(
app: &AppHandle,
state: &State<'_, TerminalState>,
cwd: Option<String>,
) -> Result<TerminalStartResponse> {
let requested_cwd = resolve_terminal_cwd(cwd)?;
let session_id = build_terminal_session_id();
let cli_binary = resolve_sidecar_binary_path(app)?;
let app_root = resolve_app_root()?;
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 100,
pixel_width: 0,
pixel_height: 0,
})
.context("open pty")?;
let shell_bootstrap = build_shell_bootstrap(
&session_id,
&requested_cwd,
&cli_binary,
&app_root,
)?;
let mut cmd = CommandBuilder::new(&shell_bootstrap.program);
cmd.cwd(&requested_cwd);
for arg in &shell_bootstrap.args {
cmd.arg(arg);
}
for (key, value) in &shell_bootstrap.env {
cmd.env(key, value);
}
let reader = pair
.master
.try_clone_reader()
.context("clone pty reader")?;
let writer = pair.master.take_writer().context("take pty writer")?;
let child = pair
.slave
.spawn_command(cmd)
.context("spawn shell in pty")?;
let output_session_id = session_id.clone();
let output_app = app.clone();
thread::spawn(move || stream_terminal_output(output_app, output_session_id, reader));
let mut guard = state
.0
.lock()
.map_err(|_| anyhow!("terminal state is unavailable"))?;
guard.insert(
session_id.clone(),
TerminalSession {
master: pair.master,
writer,
child,
cleanup_paths: shell_bootstrap.cleanup_paths,
},
);
Ok(TerminalStartResponse {
session_id,
shell: shell_bootstrap.display_name,
cwd: requested_cwd.to_string_lossy().to_string(),
explicit_command_name: "claude-haha".to_string(),
docs_command_name: "claude".to_string(),
})
}
fn stream_terminal_output(
app: AppHandle,
session_id: String,
mut reader: Box<dyn Read + Send>,
) {
let mut buffer = [0_u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
let _ = app.emit(
TERMINAL_EVENT,
TerminalEventPayload::Exit {
session_id: session_id.clone(),
exit_code: None,
},
);
break;
}
Ok(read_bytes) => {
let data = String::from_utf8_lossy(&buffer[..read_bytes]).to_string();
let _ = app.emit(
TERMINAL_EVENT,
TerminalEventPayload::Output {
session_id: session_id.clone(),
data,
},
);
}
Err(_) => {
let _ = app.emit(
TERMINAL_EVENT,
TerminalEventPayload::Exit {
session_id: session_id.clone(),
exit_code: None,
},
);
break;
}
}
}
}
struct ShellBootstrap {
program: String,
args: Vec<String>,
env: Vec<(String, String)>,
cleanup_paths: Vec<PathBuf>,
display_name: String,
}
fn build_shell_bootstrap(
session_id: &str,
cwd: &Path,
cli_binary: &Path,
app_root: &Path,
) -> Result<ShellBootstrap> {
#[cfg(target_os = "windows")]
{
let bootstrap = build_powershell_bootstrap(cwd, cli_binary, app_root);
return Ok(ShellBootstrap {
program: "powershell.exe".to_string(),
args: vec![
"-NoLogo".to_string(),
"-NoExit".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
bootstrap,
],
env: vec![
("TERM".to_string(), "xterm-256color".to_string()),
("CLAUDE_APP_ROOT".to_string(), app_root.to_string_lossy().to_string()),
],
cleanup_paths: Vec::new(),
display_name: "PowerShell".to_string(),
});
}
#[cfg(not(target_os = "windows"))]
{
let shell_path = resolve_unix_shell()?;
let rc_path = write_bash_rc_file(session_id, cwd, cli_binary, app_root)?;
return Ok(ShellBootstrap {
program: shell_path.to_string_lossy().to_string(),
args: vec![
"--noprofile".to_string(),
"--rcfile".to_string(),
rc_path.to_string_lossy().to_string(),
"-i".to_string(),
],
env: vec![
("TERM".to_string(), "xterm-256color".to_string()),
("COLORTERM".to_string(), "truecolor".to_string()),
("CLAUDE_APP_ROOT".to_string(), app_root.to_string_lossy().to_string()),
],
cleanup_paths: vec![rc_path],
display_name: "bash".to_string(),
})
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_unix_shell() -> Result<PathBuf> {
for candidate in ["/bin/bash", "/usr/bin/bash"] {
let path = PathBuf::from(candidate);
if path.exists() {
return Ok(path);
}
}
Err(anyhow!("bash is not available on this machine"))
}
#[cfg(not(target_os = "windows"))]
fn write_bash_rc_file(
session_id: &str,
cwd: &Path,
cli_binary: &Path,
app_root: &Path,
) -> Result<PathBuf> {
let rc_path = std::env::temp_dir().join(format!("cc-haha-terminal-{session_id}.bashrc"));
let home_rc = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.map(|home| home.join(".bashrc"));
let source_user_rc = if let Some(path) = home_rc.filter(|candidate| candidate.exists()) {
format!("source {} >/dev/null 2>&1 || true\n", quote_for_bash(&path.to_string_lossy()))
} else {
String::new()
};
let cli_path = quote_for_bash(&cli_binary.to_string_lossy());
let app_root_path = quote_for_bash(&app_root.to_string_lossy());
let cwd_path = quote_for_bash(&cwd.to_string_lossy());
let rc_content = format!(
"{source_user_rc}export CLAUDE_APP_ROOT={app_root_path}\n\
export PS1='\\w $ '\n\
function claude-haha() {{\n {cli_path} cli --app-root \"$CLAUDE_APP_ROOT\" \"$@\"\n}}\n\
function claude() {{\n claude-haha \"$@\"\n}}\n\
cd {cwd_path}\n"
);
fs::write(&rc_path, rc_content).context("write terminal rc file")?;
Ok(rc_path)
}
#[cfg(target_os = "windows")]
fn build_powershell_bootstrap(cwd: &Path, cli_binary: &Path, app_root: &Path) -> String {
let cli = quote_for_powershell(&cli_binary.to_string_lossy());
let root = quote_for_powershell(&app_root.to_string_lossy());
let cwd_value = quote_for_powershell(&cwd.to_string_lossy());
format!(
"$env:CLAUDE_APP_ROOT = '{root}'; \
function global:claude-haha {{ & '{cli}' cli --app-root $env:CLAUDE_APP_ROOT @Args }}; \
function global:claude {{ claude-haha @Args }}; \
Set-Location -LiteralPath '{cwd_value}'"
)
}
fn resolve_terminal_cwd(requested: Option<String>) -> Result<PathBuf> {
let path = requested
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.unwrap_or_else(default_home_dir);
if !path.exists() {
return Err(anyhow!(
"terminal working directory does not exist: {}",
path.to_string_lossy()
));
}
if !path.is_dir() {
return Err(anyhow!(
"terminal working directory is not a directory: {}",
path.to_string_lossy()
));
}
Ok(path)
}
fn default_home_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
std::env::var_os("USERPROFILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("C:\\"))
}
#[cfg(not(target_os = "windows"))]
{
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
}
}
fn resolve_app_root() -> Result<PathBuf> {
let exe = std::env::current_exe().context("resolve current exe path")?;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current exe has no parent dir"))?;
Ok(dir.to_path_buf())
}
fn resolve_sidecar_binary_path(_app: &AppHandle) -> Result<PathBuf> {
let current_exe = std::env::current_exe().context("resolve current exe path")?;
let current_dir = current_exe
.parent()
.ok_or_else(|| anyhow!("current exe has no parent dir"))?;
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let binaries_dir = manifest_dir.join("binaries");
for candidate in sidecar_name_candidates() {
let sibling = current_dir.join(&candidate);
if sibling.exists() {
return Ok(sibling);
}
let dev_binary = binaries_dir.join(&candidate);
if dev_binary.exists() {
return Ok(dev_binary);
}
}
Err(anyhow!(
"could not locate bundled sidecar near {} or {}",
current_dir.to_string_lossy(),
binaries_dir.to_string_lossy()
))
}
fn sidecar_name_candidates() -> Vec<String> {
let mut candidates = vec!["claude-sidecar".to_string()];
let triple_name = format!("claude-sidecar-{}", current_target_triple());
candidates.push(triple_name);
#[cfg(target_os = "windows")]
{
let with_exe = candidates
.iter()
.map(|name| format!("{name}.exe"))
.collect::<Vec<_>>();
candidates.extend(with_exe);
}
candidates
}
fn current_target_triple() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"aarch64-apple-darwin"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"x86_64-apple-darwin"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"x86_64-pc-windows-msvc"
}
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
{
"aarch64-pc-windows-msvc"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"x86_64-unknown-linux-gnu"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"aarch64-unknown-linux-gnu"
}
}
fn build_terminal_session_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("terminal-{millis}")
}
fn quote_for_bash(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(target_os = "windows")]
fn quote_for_powershell(value: &str) -> String {
value.replace('\'', "''")
}

View File

@ -22,8 +22,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),

View File

@ -131,6 +131,15 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
})
it('keeps install and extension tabs available after removing the embedded terminal tab', () => {
render(<Settings />)
expect(screen.getByText('Install')).toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
expect(screen.getByText('Plugins')).toBeInTheDocument()
expect(screen.queryByText('Terminal')).not.toBeInTheDocument()
})
})
describe('Settings > Providers tab', () => {

View File

@ -23,8 +23,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),

View File

@ -19,8 +19,11 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => ({
providers: [],
activeId: null,
presets: [],
isLoading: false,
isPresetsLoading: false,
fetchProviders: vi.fn(),
fetchPresets: vi.fn(),
deleteProvider: vi.fn(),
activateProvider: vi.fn(),
activateOfficial: vi.fn(),

View File

@ -1,27 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { TerminalPanel } from '../components/settings/TerminalPanel'
import { useSettingsStore } from '../stores/settingsStore'
describe('TerminalPanel', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('shows bundled CLI guidance and browser-runtime fallback text', () => {
render(<TerminalPanel />)
expect(screen.getByText('Setup Terminal')).toBeInTheDocument()
expect(
screen.getByText(/if you already have the official Claude Code installed/i),
).toBeInTheDocument()
expect(
screen.getByText(/available only inside the packaged Tauri desktop runtime/i),
).toBeInTheDocument()
expect(screen.getByText('Copy `claude-haha`')).toBeInTheDocument()
expect(screen.queryByText('Working directory')).not.toBeInTheDocument()
expect(screen.queryByText('Restart shell')).not.toBeInTheDocument()
})
})

View File

@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
const terminalMocks = vi.hoisted(() => ({
startDesktopTerminal: vi.fn(),
closeDesktopTerminal: vi.fn(),
resizeDesktopTerminal: vi.fn().mockResolvedValue(undefined),
writeDesktopTerminal: vi.fn().mockResolvedValue(undefined),
listenDesktopTerminalEvents: vi.fn().mockResolvedValue(() => {}),
}))
vi.mock('../../lib/desktopRuntime', () => ({
isTauriRuntime: () => true,
}))
vi.mock('../../lib/settingsTerminal', () => ({
startDesktopTerminal: terminalMocks.startDesktopTerminal,
closeDesktopTerminal: terminalMocks.closeDesktopTerminal,
resizeDesktopTerminal: terminalMocks.resizeDesktopTerminal,
writeDesktopTerminal: terminalMocks.writeDesktopTerminal,
listenDesktopTerminalEvents: terminalMocks.listenDesktopTerminalEvents,
}))
class MockTerminal {
cols = 100
rows = 24
loadAddon() {}
open() {}
focus() {}
clear() {}
write() {}
onData() {
return { dispose() {} }
}
dispose() {}
}
class MockFitAddon {
fit() {}
}
vi.mock('@xterm/xterm', () => ({
Terminal: MockTerminal,
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: MockFitAddon,
}))
import { TerminalPanel } from './TerminalPanel'
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
describe('TerminalPanel restart flow', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
)
terminalMocks.startDesktopTerminal.mockReset()
terminalMocks.closeDesktopTerminal.mockReset()
terminalMocks.resizeDesktopTerminal.mockClear()
terminalMocks.writeDesktopTerminal.mockClear()
terminalMocks.listenDesktopTerminalEvents.mockClear()
terminalMocks.startDesktopTerminal
.mockResolvedValueOnce({
sessionId: 'session-1',
shell: 'bash',
cwd: '/Users/nanmi',
explicitCommandName: 'claude-haha',
docsCommandName: 'claude',
})
.mockResolvedValueOnce({
sessionId: 'session-2',
shell: 'bash',
cwd: '/tmp/restarted',
explicitCommandName: 'claude-haha',
docsCommandName: 'claude',
})
})
it('switches to the new session without waiting for old close to finish', async () => {
const oldClose = createDeferred<void>()
terminalMocks.closeDesktopTerminal.mockImplementation((sessionId: string) => {
if (sessionId === 'session-1') return oldClose.promise
return Promise.resolve()
})
render(<TerminalPanel />)
await screen.findByText('Shell: bash · Working directory: /Users/nanmi')
fireEvent.click(screen.getByRole('button', { name: 'Restart shell' }))
await waitFor(() =>
expect(
screen.getByText('Shell: bash · Working directory: /tmp/restarted'),
).toBeInTheDocument(),
)
expect(terminalMocks.closeDesktopTerminal).toHaveBeenCalledWith('session-1')
oldClose.resolve()
})
})

View File

@ -1,422 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import '@xterm/xterm/css/xterm.css'
import { Button } from '../shared/Button'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import {
closeDesktopTerminal,
listenDesktopTerminalEvents,
resizeDesktopTerminal,
startDesktopTerminal,
writeDesktopTerminal,
type DesktopTerminalStart,
} from '../../lib/settingsTerminal'
type TerminalLike = {
clear(): void
dispose(): void
focus(): void
write(data: string): void
onData(listener: (data: string) => void): { dispose(): void }
cols: number
rows: number
}
type FitAddonLike = {
fit(): void
}
function readThemeVar(name: string, fallback: string) {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
return value || fallback
}
export function TerminalPanel() {
const t = useTranslation()
const addToast = useUIStore((s) => s.addToast)
const [session, setSession] = useState<DesktopTerminalStart | null>(null)
const [launchError, setLaunchError] = useState<string | null>(null)
const [isLaunching, setIsLaunching] = useState(false)
const [exitCode, setExitCode] = useState<number | null>(null)
const [terminalReady, setTerminalReady] = useState(false)
const [eventsReady, setEventsReady] = useState(false)
const hostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<TerminalLike | null>(null)
const fitRef = useRef<FitAddonLike | null>(null)
const sessionIdRef = useRef<string | null>(null)
const pendingOutputRef = useRef<string[]>([])
const autoStartedRef = useRef(false)
const isDesktop = isTauriRuntime()
const focusTerminal = () => {
terminalRef.current?.focus()
}
const closeSessionInBackground = (sessionId: string | null) => {
if (!sessionId) return
void closeDesktopTerminal(sessionId).catch((error) => {
setLaunchError(error instanceof Error ? error.message : String(error))
})
}
const flushBufferedOutput = () => {
if (!terminalRef.current || pendingOutputRef.current.length === 0) return
for (const chunk of pendingOutputRef.current) {
terminalRef.current.write(chunk)
}
pendingOutputRef.current = []
}
useEffect(() => {
if (!isDesktop || !hostRef.current || terminalRef.current) return
let disposed = false
let cleanup = () => {}
void (async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
])
if (disposed || !hostRef.current) return
const terminal = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily:
'"SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Mono", ui-monospace, monospace',
fontSize: 12.5,
lineHeight: 1.35,
scrollback: 5000,
theme: {
background: readThemeVar('--color-terminal-bg', '#121212'),
foreground: readThemeVar('--color-terminal-fg', '#d4d4d4'),
cursor: readThemeVar('--color-brand', '#d97757'),
cursorAccent: readThemeVar('--color-surface', '#ffffff'),
selectionBackground: 'rgba(217, 119, 87, 0.22)',
black: '#1f1f1f',
red: '#ff6d67',
green: '#7ef18a',
yellow: '#f8c55f',
blue: '#7aa2f7',
magenta: '#c792ea',
cyan: '#7fd1b9',
white: '#d7d2d0',
brightBlack: '#6c6763',
brightRed: '#ff8e88',
brightGreen: '#96f5a0',
brightYellow: '#ffd67c',
brightBlue: '#98b8ff',
brightMagenta: '#ddb2ff',
brightCyan: '#9be7d1',
brightWhite: '#f7f2ef',
},
})
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(hostRef.current)
terminal.focus()
terminalRef.current = terminal
fitRef.current = fitAddon
setTerminalReady(true)
flushBufferedOutput()
const scheduleResize = () => {
if (!fitRef.current || !terminalRef.current || !sessionIdRef.current) return
fitRef.current.fit()
const cols = terminalRef.current.cols
const rows = terminalRef.current.rows
if (cols > 0 && rows > 0) {
void resizeDesktopTerminal(sessionIdRef.current, cols, rows).catch(() => null)
}
}
const dataDisposable = terminal.onData((data) => {
const currentSessionId = sessionIdRef.current
if (!currentSessionId) return
void writeDesktopTerminal(currentSessionId, data).catch((error) => {
setLaunchError(
error instanceof Error ? error.message : String(error),
)
})
})
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => scheduleResize())
: null
resizeObserver?.observe(hostRef.current)
hostRef.current.addEventListener('mousedown', focusTerminal)
setTimeout(scheduleResize, 0)
cleanup = () => {
dataDisposable.dispose()
resizeObserver?.disconnect()
hostRef.current?.removeEventListener('mousedown', focusTerminal)
terminal.dispose()
terminalRef.current = null
fitRef.current = null
setTerminalReady(false)
}
})()
return () => {
disposed = true
cleanup()
}
}, [isDesktop])
useEffect(() => {
if (!isDesktop) return
let cancelled = false
let unlisten: (() => void) | undefined
void listenDesktopTerminalEvents((event) => {
if (cancelled || event.sessionId !== sessionIdRef.current) return
if (event.type === 'output') {
if (terminalRef.current) {
terminalRef.current.write(event.data)
} else {
pendingOutputRef.current.push(event.data)
}
return
}
setExitCode(event.exitCode ?? null)
if (event.exitCode !== null && event.exitCode !== undefined) {
terminalRef.current?.write(
`\r\n${t('settings.terminal.exitPrefix')} ${event.exitCode}\r\n`,
)
}
}).then((dispose) => {
unlisten = dispose
setEventsReady(true)
})
return () => {
cancelled = true
setEventsReady(false)
unlisten?.()
}
}, [isDesktop, t])
const startSession = async () => {
if (!isDesktop) return
setIsLaunching(true)
setLaunchError(null)
setExitCode(null)
const previousSessionId = sessionIdRef.current
try {
const started = await startDesktopTerminal()
sessionIdRef.current = started.sessionId
setSession(started)
terminalRef.current?.clear()
terminalRef.current?.focus()
fitRef.current?.fit()
if (terminalRef.current?.cols && terminalRef.current?.rows) {
await resizeDesktopTerminal(
started.sessionId,
terminalRef.current.cols,
terminalRef.current.rows,
).catch(() => null)
}
if (previousSessionId && previousSessionId !== started.sessionId) {
closeSessionInBackground(previousSessionId)
}
} catch (error) {
setLaunchError(error instanceof Error ? error.message : String(error))
} finally {
setIsLaunching(false)
}
}
useEffect(() => {
if (!isDesktop || !terminalReady || !eventsReady || autoStartedRef.current) return
autoStartedRef.current = true
void startSession()
return () => {
autoStartedRef.current = false
const existingSessionId = sessionIdRef.current
sessionIdRef.current = null
if (existingSessionId) {
closeSessionInBackground(existingSessionId)
}
}
}, [eventsReady, isDesktop, terminalReady])
const explicitCommand = session?.explicitCommandName || 'claude-haha'
const docsCommand = session?.docsCommandName || 'claude'
const exampleCommands = useMemo(
() => [
`${docsCommand} plugin install skill-creator@claude-plugins-official --scope user`,
`${explicitCommand} mcp add docs --transport http https://example.com/mcp`,
],
[docsCommand, explicitCommand],
)
const handleCopy = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
addToast({
type: 'success',
message: t('settings.terminal.copied'),
})
} catch (error) {
addToast({
type: 'error',
message:
error instanceof Error ? error.message : t('settings.terminal.copyFailed'),
})
}
}
return (
<div className="w-full min-w-0 max-w-6xl">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{t('settings.terminal.eyebrow')}
</div>
<h2 className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.title')}
</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.terminal.description')}
</p>
</div>
{isDesktop ? (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => void startSession()}
loading={isLaunching}
>
{t('settings.terminal.restart')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => terminalRef.current?.clear()}
>
{t('settings.terminal.clear')}
</Button>
</div>
) : null}
</div>
<div className="mt-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.docsTitle')}
</div>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{t('settings.terminal.docsBody', {
docsCommand,
explicitCommand,
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => void handleCopy(docsCommand)}
>
{t('settings.terminal.copyDocsCommand', { name: docsCommand })}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void handleCopy(explicitCommand)}
>
{t('settings.terminal.copyExplicitCommand', {
name: explicitCommand,
})}
</Button>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{exampleCommands.map((command) => (
<button
key={command}
type="button"
onClick={() => void handleCopy(command)}
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 font-[var(--font-mono)] text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{command}
</button>
))}
</div>
</div>
</section>
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
{!isDesktop ? (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-6 text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.runtimeOnly')}
</div>
) : (
<div className="min-w-0">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.sessionTitle')}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{session
? t('settings.terminal.sessionMeta', {
shell: session.shell,
cwd: session.cwd,
})
: t('settings.terminal.sessionPending')}
</div>
</div>
{exitCode !== null ? (
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-text-tertiary)]">
{t('settings.terminal.exitBadge', { code: String(exitCode) })}
</span>
) : null}
</div>
{launchError ? (
<div className="mt-4 rounded-xl border border-[var(--color-error)]/25 bg-[var(--color-error)]/8 px-4 py-3 text-sm text-[var(--color-error)]">
{launchError}
</div>
) : null}
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
<div className="flex items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-4 py-2.5">
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
<span className="ml-2 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-terminal-muted)]">
{session?.cwd || t('settings.terminal.sessionPending')}
</span>
</div>
<div
ref={hostRef}
className="h-[560px] min-h-[420px] w-full cursor-text px-2 py-2"
/>
</div>
</div>
)}
</section>
</div>
)
}

View File

@ -51,7 +51,6 @@ export const en = {
'settings.tab.providers': 'Providers',
'settings.tab.permissions': 'Permissions',
'settings.tab.general': 'General',
'settings.tab.terminal': 'Terminal',
'settings.tab.install': 'Install',
'settings.tab.skills': 'Skills',
'settings.tab.mcp': 'MCP',
@ -307,6 +306,7 @@ export const en = {
'settings.install.stop': 'Stop',
'settings.install.refresh': 'Refresh state',
'settings.install.newConversation': 'New install chat',
'settings.install.clearConversation': 'Clear chat',
'settings.install.contextDefault': 'If no directory is selected, the installer session starts from your home directory.',
'settings.install.contextUsing': 'Installer session directory: {path}',
'settings.install.contextTitle': 'Execution directory',
@ -321,27 +321,7 @@ export const en = {
'settings.install.createFailed': 'Failed to create installer session',
'settings.install.refreshDone': 'Plugin, MCP, and skill state refreshed',
'settings.install.newConversationReady': 'A fresh installer conversation is ready; your next request will start a new install context.',
'settings.terminal.eyebrow': 'Bundled CLI Terminal',
'settings.terminal.title': 'Setup Terminal',
'settings.terminal.description': 'Open a real shell inside Settings for plugin, MCP, and skill setup. This terminal wires the bundled desktop CLI into the shell so install commands can run without requiring a separate global Claude CLI.',
'settings.terminal.restart': 'Restart shell',
'settings.terminal.clear': 'Clear screen',
'settings.terminal.docsTitle': 'Docs command compatibility',
'settings.terminal.docsBody': 'If you already have the official Claude Code installed, you can keep using `{docsCommand} ...` commands. If not, use the bundled CLI here instead: `{explicitCommand} ...`.',
'settings.terminal.copyDocsCommand': 'Copy `{name}`',
'settings.terminal.copyExplicitCommand': 'Copy `{name}`',
'settings.terminal.copied': 'Command copied',
'settings.terminal.copyFailed': 'Failed to copy command',
'settings.terminal.sessionTitle': 'Interactive shell',
'settings.terminal.sessionMeta': 'Shell: {shell} · Working directory: {cwd}',
'settings.terminal.sessionPending': 'Launching shell...',
'settings.terminal.exitPrefix': 'Shell exited with code',
'settings.terminal.exitBadge': 'Exit {code}',
'settings.terminal.runtimeOnly': 'The embedded terminal is available only inside the packaged Tauri desktop runtime.',
'settings.terminal.contextTitle': 'Working directory',
'settings.terminal.contextAuto': 'Default context',
'settings.terminal.contextHint': 'Choose where the shell should start before restarting it. This is useful when an install command needs project-local files or a local MCP config target.',
'settings.terminal.contextActive': 'Current shell directory: {path}',
'settings.install.clearConversationReady': 'Installer chat cleared. Your next request will start a fresh install context.',
// Settings > Skills
'settings.skills.title': 'Installed Skills',

View File

@ -53,7 +53,6 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.providers': '服务商',
'settings.tab.permissions': '权限',
'settings.tab.general': '通用',
'settings.tab.terminal': '终端',
'settings.tab.install': '安装中心',
'settings.tab.skills': '技能',
'settings.tab.mcp': 'MCP',
@ -309,6 +308,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.install.stop': '中断执行',
'settings.install.refresh': '刷新安装状态',
'settings.install.newConversation': '新建安装会话',
'settings.install.clearConversation': '清除对话',
'settings.install.contextDefault': '未指定目录时默认使用 Home 目录启动安装会话。',
'settings.install.contextUsing': '当前安装会话目录:{path}',
'settings.install.contextTitle': '执行目录',
@ -323,27 +323,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.install.createFailed': '创建安装会话失败',
'settings.install.refreshDone': '已刷新插件、MCP 和技能状态',
'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。',
'settings.terminal.eyebrow': '内置 CLI 终端',
'settings.terminal.title': '安装与配置终端',
'settings.terminal.description': '在设置页里直接打开一个真实 shell用来安装 plugin、MCP 和 skill。这个终端会把桌面端内置 CLI 注入进去,不依赖用户额外安装全局 Claude CLI。',
'settings.terminal.restart': '重启 shell',
'settings.terminal.clear': '清空屏幕',
'settings.terminal.docsTitle': '安装命令兼容提示',
'settings.terminal.docsBody': '如果你已经安装了官方 Claude Code可以继续使用 `{docsCommand} ...` 命令;如果没有安装,就使用我们打包的 CLI也就是 `{explicitCommand} ...`。',
'settings.terminal.copyDocsCommand': '复制 `{name}`',
'settings.terminal.copyExplicitCommand': '复制 `{name}`',
'settings.terminal.copied': '命令已复制',
'settings.terminal.copyFailed': '复制命令失败',
'settings.terminal.sessionTitle': '交互式 shell',
'settings.terminal.sessionMeta': 'Shell{shell} · 工作目录:{cwd}',
'settings.terminal.sessionPending': '正在启动 shell...',
'settings.terminal.exitPrefix': 'Shell 退出,退出码',
'settings.terminal.exitBadge': '退出 {code}',
'settings.terminal.runtimeOnly': '这个内嵌终端只会在打包后的 Tauri 桌面运行时里可用。',
'settings.terminal.contextTitle': '启动目录',
'settings.terminal.contextAuto': '默认上下文',
'settings.terminal.contextHint': '如果安装命令需要项目本地文件,或者要写 local/project 级 MCP 配置,可以先在这里切目录,再重启 shell。',
'settings.terminal.contextActive': '当前 shell 目录:{path}',
'settings.install.clearConversationReady': '已清除当前安装对话;下一条请求会启动新的安装上下文。',
// Settings > Skills
'settings.skills.title': '已安装技能',

View File

@ -1,56 +0,0 @@
import { isTauriRuntime } from './desktopRuntime'
export type DesktopTerminalStart = {
sessionId: string
shell: string
cwd: string
explicitCommandName: string
docsCommandName: string
}
export type DesktopTerminalEvent =
| { type: 'output'; sessionId: string; data: string }
| { type: 'exit'; sessionId: string; exitCode?: number | null }
export async function startDesktopTerminal(cwd?: string) {
if (!isTauriRuntime()) {
throw new Error('Desktop terminal is only available in the Tauri runtime.')
}
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
return invoke<DesktopTerminalStart>('terminal_start_session', {
cwd: cwd?.trim() || null,
})
}
export async function writeDesktopTerminal(sessionId: string, data: string) {
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
return invoke('terminal_write', { sessionId, data })
}
export async function resizeDesktopTerminal(
sessionId: string,
cols: number,
rows: number,
) {
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
return invoke('terminal_resize', {
sessionId,
cols,
rows,
})
}
export async function closeDesktopTerminal(sessionId: string) {
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
return invoke('terminal_close', { sessionId })
}
export async function listenDesktopTerminalEvents(
handler: (event: DesktopTerminalEvent) => void,
) {
const { listen } = await import(/* @vite-ignore */ '@tauri-apps/api/event')
return listen<DesktopTerminalEvent>('desktop-terminal-event', (event) => {
handler(event.payload)
})
}

View File

@ -28,7 +28,6 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
import { InstallCenter } from '../components/settings/InstallCenter'
import { TerminalPanel } from '../components/settings/TerminalPanel'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -51,7 +50,6 @@ export function Settings() {
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
@ -70,7 +68,6 @@ export function Settings() {
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'terminal' && <TerminalPanel />}
{activeTab === 'install' && <InstallCenter />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}