From 376e255b6b480d78a6acc60fed445919c1fe3097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 22 Apr 2026 18:07:09 +0800 Subject: [PATCH] feat: add a bundled desktop setup terminal with safer restart handoff Settings needed a real shell for plugin, MCP, and skill setup without relying on a globally installed Claude CLI. Add an xterm.js terminal backed by portable-pty, wire it into the Tauri desktop runtime, and move shell restart handoff to the new session before old PTY teardown so the UI is less likely to stall behind child shutdown. Constraint: The desktop app must inject the bundled CLI into the shell environment instead of requiring a separate global install Constraint: Restart teardown cannot block the frontend-facing Tauri command path Rejected: Keep terminal setup inside installer chat only | that flow cannot replace an interactive shell Rejected: Wait for old PTY shutdown before adopting the new session | it keeps restart vulnerable to hung child teardown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve new-session handoff before old-session cleanup when changing terminal lifecycle or restart logic Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: Full packaged-app command echo and repeated manual restart behavior still need additional runtime verification --- desktop/bun.lock | 6 + desktop/package.json | 2 + desktop/src-tauri/Cargo.lock | 102 +++- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/lib.rs | 17 +- desktop/src-tauri/src/terminal.rs | 522 ++++++++++++++++++ desktop/src/__tests__/terminalPanel.test.tsx | 27 + .../settings/TerminalPanel.restart.test.tsx | 127 +++++ .../src/components/settings/TerminalPanel.tsx | 422 ++++++++++++++ desktop/src/i18n/locales/en.ts | 22 + desktop/src/i18n/locales/zh.ts | 22 + desktop/src/lib/settingsTerminal.ts | 56 ++ desktop/src/pages/Settings.tsx | 5 +- 13 files changed, 1329 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/src/terminal.rs create mode 100644 desktop/src/__tests__/terminalPanel.test.tsx create mode 100644 desktop/src/components/settings/TerminalPanel.restart.test.tsx create mode 100644 desktop/src/components/settings/TerminalPanel.tsx create mode 100644 desktop/src/lib/settingsTerminal.ts diff --git a/desktop/bun.lock b/desktop/bun.lock index 52d20da9..13c4092d 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -7,6 +7,8 @@ "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", @@ -470,6 +472,10 @@ "@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=="], diff --git a/desktop/package.json b/desktop/package.json index b43d7818..7cd33e83 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -18,6 +18,8 @@ "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", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 98b02845..8933a60e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -309,6 +309,12 @@ 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" @@ -325,6 +331,8 @@ dependencies = [ name = "claude-code-desktop" version = "0.1.5" dependencies = [ + "anyhow", + "portable-pty", "serde", "serde_json", "tauri", @@ -675,6 +683,12 @@ 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" @@ -722,7 +736,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -792,6 +806,17 @@ 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" @@ -1771,6 +1796,12 @@ 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" @@ -2009,6 +2040,18 @@ 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" @@ -2533,6 +2576,27 @@ 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" @@ -3291,6 +3355,17 @@ 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" @@ -3354,6 +3429,22 @@ 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" @@ -5242,6 +5333,15 @@ 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" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 03f749e0..c1feff6a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,3 +18,5 @@ 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" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7671fce4..cdfa5a5e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -16,6 +16,13 @@ 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); @@ -298,13 +305,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 + restart_adapters_sidecar, + terminal_start_session, + terminal_write, + terminal_resize, + terminal_close ]); // macOS: native menu bar (traffic-light overlay style) @@ -403,6 +415,9 @@ 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::() { + stop_all_sessions(&state); + } } }); } diff --git a/desktop/src-tauri/src/terminal.rs b/desktop/src-tauri/src/terminal.rs new file mode 100644 index 00000000..af01943b --- /dev/null +++ b/desktop/src-tauri/src/terminal.rs @@ -0,0 +1,522 @@ +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, + writer: Box, + child: Box, + cleanup_paths: Vec, +} + +#[derive(Default)] +pub struct TerminalState(pub Mutex>); + +#[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 }, +} + +#[tauri::command] +pub fn terminal_start_session( + app: AppHandle, + state: State<'_, TerminalState>, + cwd: Option, +) -> Result { + 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::>() + } 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, +) -> Result { + 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, +) { + 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, + env: Vec<(String, String)>, + cleanup_paths: Vec, + display_name: String, +} + +fn build_shell_bootstrap( + session_id: &str, + cwd: &Path, + cli_binary: &Path, + app_root: &Path, +) -> Result { + #[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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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::>(); + 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('\'', "''") +} diff --git a/desktop/src/__tests__/terminalPanel.test.tsx b/desktop/src/__tests__/terminalPanel.test.tsx new file mode 100644 index 00000000..622ca2b9 --- /dev/null +++ b/desktop/src/__tests__/terminalPanel.test.tsx @@ -0,0 +1,27 @@ +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() + + 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() + }) +}) diff --git a/desktop/src/components/settings/TerminalPanel.restart.test.tsx b/desktop/src/components/settings/TerminalPanel.restart.test.tsx new file mode 100644 index 00000000..2c23736c --- /dev/null +++ b/desktop/src/components/settings/TerminalPanel.restart.test.tsx @@ -0,0 +1,127 @@ +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() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((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() + terminalMocks.closeDesktopTerminal.mockImplementation((sessionId: string) => { + if (sessionId === 'session-1') return oldClose.promise + return Promise.resolve() + }) + + render() + + 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() + }) +}) diff --git a/desktop/src/components/settings/TerminalPanel.tsx b/desktop/src/components/settings/TerminalPanel.tsx new file mode 100644 index 00000000..d582476f --- /dev/null +++ b/desktop/src/components/settings/TerminalPanel.tsx @@ -0,0 +1,422 @@ +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(null) + const [launchError, setLaunchError] = useState(null) + const [isLaunching, setIsLaunching] = useState(false) + const [exitCode, setExitCode] = useState(null) + const [terminalReady, setTerminalReady] = useState(false) + const [eventsReady, setEventsReady] = useState(false) + const hostRef = useRef(null) + const terminalRef = useRef(null) + const fitRef = useRef(null) + const sessionIdRef = useRef(null) + const pendingOutputRef = useRef([]) + 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 ( +
+
+
+
+
+ {t('settings.terminal.eyebrow')} +
+

+ {t('settings.terminal.title')} +

+

+ {t('settings.terminal.description')} +

+
+ + {isDesktop ? ( +
+ + +
+ ) : null} +
+ +
+
+
+
+ {t('settings.terminal.docsTitle')} +
+

+ {t('settings.terminal.docsBody', { + docsCommand, + explicitCommand, + })} +

+
+ +
+ + +
+
+ +
+ {exampleCommands.map((command) => ( + + ))} +
+
+
+ +
+ {!isDesktop ? ( +
+ {t('settings.terminal.runtimeOnly')} +
+ ) : ( +
+
+
+
+ {t('settings.terminal.sessionTitle')} +
+
+ {session + ? t('settings.terminal.sessionMeta', { + shell: session.shell, + cwd: session.cwd, + }) + : t('settings.terminal.sessionPending')} +
+
+ {exitCode !== null ? ( + + {t('settings.terminal.exitBadge', { code: String(exitCode) })} + + ) : null} +
+ + {launchError ? ( +
+ {launchError} +
+ ) : null} + +
+
+
+
+
+ + {session?.cwd || t('settings.terminal.sessionPending')} + +
+
+
+
+ )} +
+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 51999a77..e4eb4615 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -51,6 +51,7 @@ 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', @@ -320,6 +321,27 @@ 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 > Skills 'settings.skills.title': 'Installed Skills', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 54295417..8fbe6fc0 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -53,6 +53,7 @@ export const zh: Record = { 'settings.tab.providers': '服务商', 'settings.tab.permissions': '权限', 'settings.tab.general': '通用', + 'settings.tab.terminal': '终端', 'settings.tab.install': '安装中心', 'settings.tab.skills': '技能', 'settings.tab.mcp': 'MCP', @@ -322,6 +323,27 @@ export const zh: Record = { '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 > Skills 'settings.skills.title': '已安装技能', diff --git a/desktop/src/lib/settingsTerminal.ts b/desktop/src/lib/settingsTerminal.ts new file mode 100644 index 00000000..05dce042 --- /dev/null +++ b/desktop/src/lib/settingsTerminal.ts @@ -0,0 +1,56 @@ +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('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('desktop-terminal-event', (event) => { + handler(event.payload) + }) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 643d894c..dea53f1b 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -28,6 +28,7 @@ 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('providers') @@ -50,6 +51,7 @@ export function Settings() { setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('adapters')} /> + setActiveTab('terminal')} /> setActiveTab('install')} /> setActiveTab('mcp')} /> setActiveTab('agents')} /> @@ -68,6 +70,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'general' && } {activeTab === 'adapters' && } + {activeTab === 'terminal' && } {activeTab === 'install' && } {activeTab === 'mcp' && } {activeTab === 'agents' && } @@ -1440,7 +1443,7 @@ function AboutSettings() { return (
{/* Logo + App Name + Version */} - Claude Code Haha + Claude Code Haha

Claude Code Haha

{version && ( {t('settings.about.version')} {version}