mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
376e255b6b
commit
48da5ccbfa
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
101
desktop/src-tauri/Cargo.lock
generated
101
desktop/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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('\'', "''")
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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': '已安装技能',
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user