From 0c58a9f36f47649b2437a806c92574191a6e8d4d 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: Fri, 24 Apr 2026 09:36:00 +0800 Subject: [PATCH] Give desktop users a real terminal for command-based setup Some setup flows end in a shell command instead of a natural-language install path, so Settings now exposes a host PTY terminal backed by portable-pty and xterm. The terminal inherits the user's login-shell environment, forces a UTF-8 locale when needed, and preserves split UTF-8 output so Chinese paths render correctly. Constraint: Desktop GUI apps do not inherit the user's interactive shell PATH on macOS. Constraint: Command output may split UTF-8 characters across PTY reads. Rejected: Use Tauri shell commands only | users need an interactive PTY for copy-pasted install flows. Rejected: Ask users to edit shell profiles | terminal setup should work out of the box. Confidence: high Scope-risk: moderate Directive: Keep terminal startup tied to host environment checks; do not bundle runtimes to solve PATH issues. Tested: cargo fmt --check; cargo test --lib; cargo check; cd desktop && bun run lint; cd desktop && bun run test src/pages/TerminalSettings.test.tsx; cd desktop && bun run build; cd desktop && bun run build:macos-arm64 Tested: Computer Use verified npm is available in the built macOS app terminal and Chinese output renders after UTF-8 decoding fix. Not-tested: Native Windows/Linux package runtime validation. --- desktop/bun.lock | 6 + desktop/package.json | 2 + desktop/src-tauri/Cargo.lock | 101 +++- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 536 +++++++++++++++++++- desktop/src/api/terminal.ts | 58 +++ desktop/src/i18n/locales/en.ts | 16 + desktop/src/i18n/locales/zh.ts | 16 + desktop/src/pages/Settings.tsx | 3 + desktop/src/pages/TerminalSettings.test.tsx | 128 +++++ desktop/src/pages/TerminalSettings.tsx | 275 ++++++++++ desktop/src/stores/uiStore.ts | 1 + desktop/src/theme/globals.css | 14 + 13 files changed, 1145 insertions(+), 12 deletions(-) create mode 100644 desktop/src/api/terminal.ts create mode 100644 desktop/src/pages/TerminalSettings.test.tsx create mode 100644 desktop/src/pages/TerminalSettings.tsx 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..75b90785 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,7 @@ dependencies = [ name = "claude-code-desktop" version = "0.1.5" dependencies = [ + "portable-pty", "serde", "serde_json", "tauri", @@ -675,6 +682,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 +735,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -792,6 +805,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 +1795,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 +2039,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 +2575,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 +3354,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 +3428,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 +5332,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..38ea1670 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,3 +18,4 @@ tauri-plugin-process = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +portable-pty = "0.9.0" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7671fce4..44b68110 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,14 +1,22 @@ use std::{ - io::{Error as IoError, ErrorKind}, + collections::HashMap, + io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, - sync::Mutex, + process::{Command as StdCommand, Stdio}, + str, + sync::{ + atomic::{AtomicU32, Ordering}, + Mutex, + }, + thread, time::{Duration, Instant}, }; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; #[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -#[cfg(target_os = "macos")] use tauri::Emitter; use tauri::{AppHandle, Manager, RunEvent, State}; use tauri_plugin_shell::{ @@ -39,6 +47,38 @@ struct ServerStatus { #[derive(Default)] struct AdapterState(Mutex>); +#[derive(Default)] +struct TerminalState { + next_id: AtomicU32, + sessions: Mutex>, +} + +struct TerminalSession { + master: Box, + writer: Mutex>, + killer: Mutex>, +} + +#[derive(Serialize, Clone)] +struct TerminalSpawnResult { + session_id: u32, + shell: String, + cwd: String, +} + +#[derive(Serialize, Clone)] +struct TerminalOutputPayload { + session_id: u32, + data: String, +} + +#[derive(Serialize, Clone)] +struct TerminalExitPayload { + session_id: u32, + code: u32, + signal: Option, +} + #[tauri::command] fn get_server_url(state: State<'_, ServerState>) -> Result { let guard = state @@ -72,6 +112,395 @@ fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn terminal_spawn( + app: AppHandle, + state: State<'_, TerminalState>, + cols: u16, + rows: u16, + cwd: Option, +) -> Result { + let cwd_path = resolve_terminal_cwd(cwd)?; + let shell = default_shell(); + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("open terminal pty: {err}"))?; + + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd_path.as_os_str()); + for (key, value) in terminal_environment(&shell) { + cmd.env(key, value); + } + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|err| format!("spawn terminal shell: {err}"))?; + drop(pair.slave); + + let mut reader = pair + .master + .try_clone_reader() + .map_err(|err| format!("clone terminal reader: {err}"))?; + let writer = pair + .master + .take_writer() + .map_err(|err| format!("open terminal writer: {err}"))?; + let killer = child.clone_killer(); + let session_id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1; + + { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.insert( + session_id, + TerminalSession { + master: pair.master, + writer: Mutex::new(writer), + killer: Mutex::new(killer), + }, + ); + } + + let output_app = app.clone(); + thread::spawn(move || { + let mut buffer = [0_u8; 8192]; + let mut pending_utf8 = Vec::new(); + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let data = decode_terminal_output(&mut pending_utf8, &buffer[..n]); + if !data.is_empty() { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + } + Err(err) => { + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal read error: {err}]\r\n"), + }, + ); + break; + } + } + } + if !pending_utf8.is_empty() { + let data = String::from_utf8_lossy(&pending_utf8).to_string(); + let _ = output_app.emit( + "terminal-output", + TerminalOutputPayload { session_id, data }, + ); + } + }); + + let exit_app = app.clone(); + thread::spawn(move || { + let status = child.wait(); + if let Some(state) = exit_app.try_state::() { + if let Ok(mut sessions) = state.sessions.lock() { + sessions.remove(&session_id); + } + } + match status { + Ok(status) => { + let _ = exit_app.emit( + "terminal-exit", + TerminalExitPayload { + session_id, + code: status.exit_code(), + signal: status.signal().map(ToString::to_string), + }, + ); + } + Err(err) => { + let _ = exit_app.emit( + "terminal-output", + TerminalOutputPayload { + session_id, + data: format!("\r\n[terminal wait error: {err}]\r\n"), + }, + ); + } + } + }); + + Ok(TerminalSpawnResult { + session_id, + shell, + cwd: cwd_path.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +fn terminal_write( + state: State<'_, TerminalState>, + session_id: u32, + data: String, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + let mut writer = session + .writer + .lock() + .map_err(|_| "terminal writer is unavailable".to_string())?; + writer + .write_all(data.as_bytes()) + .map_err(|err| format!("write terminal input: {err}"))?; + writer + .flush() + .map_err(|err| format!("flush terminal input: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_resize( + state: State<'_, TerminalState>, + session_id: u32, + cols: u16, + rows: u16, +) -> Result<(), String> { + let sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = sessions + .get(&session_id) + .ok_or_else(|| "terminal session is not running".to_string())?; + session + .master + .resize(PtySize { + rows: rows.max(8), + cols: cols.max(20), + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("resize terminal: {err}"))?; + Ok(()) +} + +#[tauri::command] +fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(), String> { + let session = { + let mut sessions = state + .sessions + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + sessions.remove(&session_id) + }; + + if let Some(session) = session { + let mut killer = session + .killer + .lock() + .map_err(|_| "terminal killer is unavailable".to_string())?; + killer + .kill() + .map_err(|err| format!("kill terminal shell: {err}"))?; + } + Ok(()) +} + +fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { + pending.extend_from_slice(chunk); + let mut output = String::new(); + + loop { + match str::from_utf8(pending) { + Ok(text) => { + output.push_str(text); + pending.clear(); + break; + } + Err(err) => { + let valid_up_to = err.valid_up_to(); + if valid_up_to > 0 { + let text = str::from_utf8(&pending[..valid_up_to]) + .expect("valid_up_to marks a valid UTF-8 prefix"); + output.push_str(text); + pending.drain(..valid_up_to); + continue; + } + + match err.error_len() { + Some(error_len) => { + output.push('\u{fffd}'); + pending.drain(..error_len); + } + None => break, + } + } + } + } + + output +} + +fn terminal_environment(shell: &str) -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + env.extend(login_shell_environment(shell)); + ensure_utf8_locale(&mut env); + env +} + +fn ensure_utf8_locale(env: &mut HashMap) { + let fallback = default_utf8_locale(); + for key in ["LANG", "LC_CTYPE", "LC_ALL"] { + let needs_fallback = env + .get(key) + .map(|value| !is_utf8_locale(value)) + .unwrap_or(true); + if needs_fallback { + env.insert(key.to_string(), fallback.to_string()); + } + } +} + +fn is_utf8_locale(value: &str) -> bool { + let normalized = value.trim().to_ascii_lowercase().replace('-', ""); + normalized.contains("utf8") +} + +fn default_utf8_locale() -> &'static str { + #[cfg(target_os = "macos")] + { + "en_US.UTF-8" + } + #[cfg(all(unix, not(target_os = "macos")))] + { + "C.UTF-8" + } + #[cfg(not(unix))] + { + "C.UTF-8" + } +} + +#[cfg(not(target_os = "windows"))] +fn login_shell_environment(shell: &str) -> HashMap { + let Ok(mut child) = StdCommand::new(shell) + .args(["-l", "-c", "env -0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return HashMap::new(); + }; + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return HashMap::new(); + } + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + return parse_env_block(&stdout); + } + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return HashMap::new(); + } + Err(_) => return HashMap::new(), + } + } +} + +#[cfg(target_os = "windows")] +fn login_shell_environment(_shell: &str) -> HashMap { + HashMap::new() +} + +fn parse_env_block(bytes: &[u8]) -> HashMap { + bytes + .split(|byte| *byte == 0) + .filter_map(|entry| { + if entry.is_empty() { + return None; + } + let equals = entry.iter().position(|byte| *byte == b'=')?; + if equals == 0 { + return None; + } + let key = String::from_utf8_lossy(&entry[..equals]).to_string(); + let value = String::from_utf8_lossy(&entry[equals + 1..]).to_string(); + Some((key, value)) + }) + .collect() +} + +fn resolve_terminal_cwd(cwd: Option) -> Result { + let path = match cwd.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + }) { + Some(path) => path, + None => home_dir().unwrap_or( + std::env::current_dir().map_err(|err| format!("resolve current directory: {err}"))?, + ), + }; + + if path.is_dir() { + Ok(path) + } else { + Err(format!("terminal cwd does not exist: {}", path.display())) + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn default_shell() -> String { + #[cfg(target_os = "windows")] + { + std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string()) + } + #[cfg(not(target_os = "windows"))] + { + std::env::var("SHELL").unwrap_or_else(|_| { + if PathBuf::from("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else { + "/bin/bash".to_string() + } + }) + } +} + fn reserve_local_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?; @@ -114,8 +543,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result { // 我们直接用当前可执行文件所在目录作为 app_root: // Dev: desktop/src-tauri/target// (rust 跑出来的 binary 那一层) // Prod: .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())? @@ -293,26 +721,114 @@ fn stop_adapters_sidecar(app: &AppHandle) { } } +#[cfg(test)] +mod tests { + use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block}; + use std::collections::HashMap; + + #[test] + fn terminal_output_decoder_preserves_split_chinese_characters() { + let mut pending = Vec::new(); + let bytes = "安装 Skills 成功\n".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..2]), ""); + assert_eq!(decode_terminal_output(&mut pending, &bytes[2..4]), "安"); + assert_eq!( + decode_terminal_output(&mut pending, &bytes[4..]), + "装 Skills 成功\n" + ); + assert!(pending.is_empty()); + } + + #[test] + fn terminal_output_decoder_keeps_incomplete_suffix_pending() { + let mut pending = Vec::new(); + let bytes = "中文".as_bytes(); + + assert_eq!(decode_terminal_output(&mut pending, &bytes[..4]), "中"); + assert_eq!(pending, bytes[3..4]); + assert_eq!(decode_terminal_output(&mut pending, &bytes[4..]), "文"); + assert!(pending.is_empty()); + } + + #[test] + fn parse_env_block_reads_nul_delimited_values() { + let env = + parse_env_block(b"PATH=/opt/homebrew/bin:/usr/bin\0NODE_PATH=/tmp/node\0EMPTY=\0"); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/opt/homebrew/bin:/usr/bin") + ); + assert_eq!(env.get("NODE_PATH").map(String::as_str), Some("/tmp/node")); + assert_eq!(env.get("EMPTY").map(String::as_str), Some("")); + } + + #[test] + fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "C".to_string()), + ("LC_CTYPE".to_string(), "POSIX".to_string()), + ("LC_ALL".to_string(), "C".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!( + env.get("LANG").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_CTYPE").map(String::as_str), + Some(default_utf8_locale()) + ); + assert_eq!( + env.get("LC_ALL").map(String::as_str), + Some(default_utf8_locale()) + ); + } + + #[test] + fn terminal_environment_keeps_existing_utf8_locale() { + let mut env = HashMap::from([ + ("LANG".to_string(), "zh_CN.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "en_US.UTF8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ]); + + ensure_utf8_locale(&mut env); + + assert_eq!(env.get("LANG").map(String::as_str), Some("zh_CN.UTF-8")); + assert_eq!(env.get("LC_CTYPE").map(String::as_str), Some("en_US.UTF8")); + assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] 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_spawn, + terminal_write, + terminal_resize, + terminal_kill ]); // 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)?; @@ -341,9 +857,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() diff --git a/desktop/src/api/terminal.ts b/desktop/src/api/terminal.ts new file mode 100644 index 00000000..cd09a6fa --- /dev/null +++ b/desktop/src/api/terminal.ts @@ -0,0 +1,58 @@ +import { isTauriRuntime } from '../lib/desktopRuntime' + +export type TerminalSpawnResult = { + session_id: number + shell: string + cwd: string +} + +export type TerminalOutputPayload = { + session_id: number + data: string +} + +export type TerminalExitPayload = { + session_id: number + code: number + signal?: string | null +} + +type Unlisten = () => void + +async function invoke(command: string, args?: Record): Promise { + if (!isTauriRuntime()) { + throw new Error('Terminal is available in the desktop app runtime.') + } + const api = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return api.invoke(command, args) +} + +export const terminalApi = { + isAvailable: isTauriRuntime, + + spawn(input: { cols: number; rows: number; cwd?: string }) { + return invoke('terminal_spawn', input) + }, + + write(sessionId: number, data: string) { + return invoke('terminal_write', { sessionId, data }) + }, + + resize(sessionId: number, cols: number, rows: number) { + return invoke('terminal_resize', { sessionId, cols, rows }) + }, + + kill(sessionId: number) { + return invoke('terminal_kill', { sessionId }) + }, + + async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-output', (event) => handler(event.payload)) + }, + + async onExit(handler: (payload: TerminalExitPayload) => void): Promise { + const events = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return events.listen('terminal-exit', (event) => handler(event.payload)) + }, +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1cb6335f..9ceae015 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -51,10 +51,26 @@ export const en = { 'settings.tab.permissions': 'Permissions', 'settings.tab.general': 'General', 'settings.tab.install': 'Install', + 'settings.tab.terminal': 'Terminal', 'settings.tab.skills': 'Skills', 'settings.tab.mcp': 'MCP', 'settings.tab.plugins': 'Plugins', + // Settings > Terminal + 'settings.terminal.title': 'Terminal', + 'settings.terminal.description': 'Run host-machine commands for plugin, skill, and MCP setup without leaving the desktop app.', + 'settings.terminal.clear': 'Clear', + 'settings.terminal.restart': 'Restart', + 'settings.terminal.windowTitle': 'Host shell', + 'settings.terminal.unavailableTitle': 'Desktop runtime required', + 'settings.terminal.unavailableBody': 'Open this page in the packaged desktop app to start an interactive terminal.', + 'settings.terminal.status.idle': 'Idle', + 'settings.terminal.status.starting': 'Starting', + 'settings.terminal.status.running': 'Running', + 'settings.terminal.status.exited': 'Exited', + 'settings.terminal.status.error': 'Error', + 'settings.terminal.status.unavailable': 'Unavailable', + // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.', 'settings.claudeOfficialLogin.loginButton': 'Sign in to Claude', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index bba0f4da..5c3c7c1e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -53,10 +53,26 @@ export const zh: Record = { 'settings.tab.permissions': '权限', 'settings.tab.general': '通用', 'settings.tab.install': '安装中心', + 'settings.tab.terminal': '终端', 'settings.tab.skills': '技能', 'settings.tab.mcp': 'MCP', 'settings.tab.plugins': '插件', + // Settings > Terminal + 'settings.terminal.title': '终端', + 'settings.terminal.description': '直接运行宿主机命令,用于安装插件、Skills、MCP 等需要命令行的扩展。', + 'settings.terminal.clear': '清屏', + 'settings.terminal.restart': '重启', + 'settings.terminal.windowTitle': '宿主机 Shell', + 'settings.terminal.unavailableTitle': '需要桌面端运行时', + 'settings.terminal.unavailableBody': '请在打包后的桌面端里打开这个页面,才能启动交互式终端。', + 'settings.terminal.status.idle': '空闲', + 'settings.terminal.status.starting': '启动中', + 'settings.terminal.status.running': '运行中', + 'settings.terminal.status.exited': '已退出', + 'settings.terminal.status.error': '错误', + 'settings.terminal.status.unavailable': '不可用', + // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。', 'settings.claudeOfficialLogin.loginButton': '登录 Claude 账号', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 6c059d63..ab74fd51 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -23,6 +23,7 @@ import { PluginList } from '../components/plugins/PluginList' import { PluginDetail } from '../components/plugins/PluginDetail' import { ComputerUseSettings } from './ComputerUseSettings' import { McpSettings } from './McpSettings' +import { TerminalSettings } from './TerminalSettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' @@ -51,6 +52,7 @@ export function Settings() { setActiveTab('general')} /> setActiveTab('adapters')} /> setActiveTab('install')} /> + setActiveTab('terminal')} /> setActiveTab('mcp')} /> setActiveTab('agents')} /> setActiveTab('skills')} /> @@ -69,6 +71,7 @@ export function Settings() { {activeTab === 'general' && } {activeTab === 'adapters' && } {activeTab === 'install' && } + {activeTab === 'terminal' && } {activeTab === 'mcp' && } {activeTab === 'agents' && } {activeTab === 'skills' && } diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx new file mode 100644 index 00000000..04e72a98 --- /dev/null +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -0,0 +1,128 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const terminalMocks = vi.hoisted(() => { + const terminalInstance = { + cols: 80, + rows: 24, + loadAddon: vi.fn(), + open: vi.fn(), + dispose: vi.fn(), + onData: vi.fn(), + write: vi.fn(), + writeln: vi.fn(), + clear: vi.fn(), + } + const fitInstance = { + fit: vi.fn(), + } + return { + available: false, + terminalInstance, + fitInstance, + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onOutput: vi.fn(), + onExit: vi.fn(), + } +}) + +vi.mock('@xterm/xterm', () => ({ + Terminal: vi.fn(() => terminalMocks.terminalInstance), +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: vi.fn(() => terminalMocks.fitInstance), +})) + +vi.mock('../api/terminal', () => ({ + terminalApi: { + isAvailable: () => terminalMocks.available, + spawn: terminalMocks.spawn, + write: terminalMocks.write, + resize: terminalMocks.resize, + kill: terminalMocks.kill, + onOutput: terminalMocks.onOutput, + onExit: terminalMocks.onExit, + }, +})) + +import { TerminalSettings } from './TerminalSettings' + +describe('TerminalSettings', () => { + beforeEach(() => { + terminalMocks.available = false + terminalMocks.spawn.mockReset() + terminalMocks.write.mockReset() + terminalMocks.resize.mockReset() + terminalMocks.kill.mockReset() + terminalMocks.onOutput.mockReset() + terminalMocks.onExit.mockReset() + terminalMocks.terminalInstance.loadAddon.mockClear() + terminalMocks.terminalInstance.open.mockClear() + terminalMocks.terminalInstance.dispose.mockClear() + terminalMocks.terminalInstance.onData.mockClear() + terminalMocks.terminalInstance.write.mockClear() + terminalMocks.terminalInstance.writeln.mockClear() + terminalMocks.terminalInstance.clear.mockClear() + terminalMocks.fitInstance.fit.mockClear() + terminalMocks.onOutput.mockResolvedValue(vi.fn()) + terminalMocks.onExit.mockResolvedValue(vi.fn()) + terminalMocks.write.mockResolvedValue(undefined) + terminalMocks.resize.mockResolvedValue(undefined) + terminalMocks.kill.mockResolvedValue(undefined) + terminalMocks.spawn.mockResolvedValue({ + session_id: 7, + shell: '/bin/zsh', + cwd: '/Users/test', + }) + vi.stubGlobal('ResizeObserver', class { + observe = vi.fn() + disconnect = vi.fn() + }) + }) + + it('shows a desktop-runtime empty state outside Tauri', () => { + render() + + expect(screen.getByText('Desktop runtime required')).toBeInTheDocument() + expect(terminalMocks.spawn).not.toHaveBeenCalled() + }) + + it('starts a host terminal session when Tauri is available', async () => { + terminalMocks.available = true + + render() + + await waitFor(() => { + expect(terminalMocks.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) + }) + expect(screen.getByText('/bin/zsh')).toBeInTheDocument() + expect(screen.getByText('/Users/test')).toBeInTheDocument() + expect(terminalMocks.terminalInstance.open).toHaveBeenCalled() + expect(terminalMocks.fitInstance.fit).toHaveBeenCalled() + }) + + it('writes matching terminal output events into xterm', async () => { + terminalMocks.available = true + let outputHandler: ((payload: { session_id: number; data: string }) => void) | undefined + terminalMocks.onOutput.mockImplementation(async (handler) => { + outputHandler = handler + return vi.fn() + }) + + render() + await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled()) + + act(() => { + outputHandler?.({ session_id: 7, data: 'hello\r\n' }) + outputHandler?.({ session_id: 8, data: 'ignored\r\n' }) + }) + + expect(terminalMocks.terminalInstance.write).toHaveBeenCalledWith('hello\r\n') + expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') + }) +}) diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx new file mode 100644 index 00000000..e944fb68 --- /dev/null +++ b/desktop/src/pages/TerminalSettings.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { Terminal as XTermTerminal } from '@xterm/xterm' +import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit' +import { useTranslation, type TranslationKey } from '../i18n' +import { terminalApi } from '../api/terminal' + +type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' + +const STATUS_LABEL_KEYS: Record = { + idle: 'settings.terminal.status.idle', + starting: 'settings.terminal.status.starting', + running: 'settings.terminal.status.running', + exited: 'settings.terminal.status.exited', + error: 'settings.terminal.status.error', + unavailable: 'settings.terminal.status.unavailable', +} + +export function TerminalSettings() { + const t = useTranslation() + const hostRef = useRef(null) + const terminalRef = useRef(null) + const fitRef = useRef(null) + const sessionIdRef = useRef(null) + const unlistenRef = useRef void>>([]) + const [status, setStatus] = useState(() => terminalApi.isAvailable() ? 'idle' : 'unavailable') + const [error, setError] = useState(null) + const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null) + + const resizeSession = useCallback(() => { + const terminal = terminalRef.current + const fit = fitRef.current + const sessionId = sessionIdRef.current + if (!terminal || !fit) return + + fit.fit() + if (sessionId) { + void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {}) + } + }, []) + + const startTerminal = useCallback(async () => { + if (!terminalApi.isAvailable()) { + setStatus('unavailable') + return + } + + const host = hostRef.current + if (!host) return + + setError(null) + setStatus('starting') + setShellInfo(null) + + const existing = sessionIdRef.current + if (existing) { + await terminalApi.kill(existing).catch(() => {}) + sessionIdRef.current = null + } + unlistenRef.current.forEach((unlisten) => unlisten()) + unlistenRef.current = [] + + terminalRef.current?.dispose() + fitRef.current = null + host.innerHTML = '' + + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + ]) + + const terminal = new Terminal({ + cursorBlink: true, + convertEol: false, + fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace", + fontSize: 12, + lineHeight: 1.25, + scrollback: 4000, + theme: { + background: '#121212', + foreground: '#d7d2d0', + cursor: '#ffb59f', + selectionBackground: '#5f4a40', + black: '#1f1f1f', + red: '#ff6d67', + green: '#7ef18a', + yellow: '#f8c55f', + blue: '#77a8ff', + magenta: '#d699ff', + cyan: '#61d6d6', + white: '#d7d2d0', + brightBlack: '#8f8683', + brightRed: '#ff8a85', + brightGreen: '#9ff7a7', + brightYellow: '#ffdd7a', + brightBlue: '#a6c5ff', + brightMagenta: '#e3b8ff', + brightCyan: '#8ceeee', + brightWhite: '#ffffff', + }, + }) + const fit = new FitAddon() + terminal.loadAddon(fit) + terminal.open(host) + terminalRef.current = terminal + fitRef.current = fit + fit.fit() + + const outputUnlisten = await terminalApi.onOutput((payload) => { + if (payload.session_id === sessionIdRef.current) { + terminal.write(payload.data) + } + }) + const exitUnlisten = await terminalApi.onExit((payload) => { + if (payload.session_id !== sessionIdRef.current) return + setStatus('exited') + const signal = payload.signal ? `, ${payload.signal}` : '' + terminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`) + sessionIdRef.current = null + }) + unlistenRef.current = [outputUnlisten, exitUnlisten] + + terminal.onData((data) => { + const sessionId = sessionIdRef.current + if (sessionId) { + void terminalApi.write(sessionId, data).catch((err) => { + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') + }) + } + }) + + try { + const result = await terminalApi.spawn({ cols: terminal.cols, rows: terminal.rows }) + sessionIdRef.current = result.session_id + setShellInfo({ shell: result.shell, cwd: result.cwd }) + setStatus('running') + resizeSession() + } catch (err) { + outputUnlisten() + exitUnlisten() + terminal.dispose() + terminalRef.current = null + fitRef.current = null + setError(err instanceof Error ? err.message : String(err)) + setStatus('error') + } + }, [resizeSession]) + + useEffect(() => { + if (!terminalApi.isAvailable()) return + void startTerminal() + + const observer = new ResizeObserver(() => resizeSession()) + if (hostRef.current) observer.observe(hostRef.current) + + return () => { + observer.disconnect() + const sessionId = sessionIdRef.current + if (sessionId) { + void terminalApi.kill(sessionId).catch(() => {}) + } + terminalRef.current?.dispose() + terminalRef.current = null + fitRef.current = null + unlistenRef.current.forEach((unlisten) => unlisten()) + unlistenRef.current = [] + sessionIdRef.current = null + } + }, [resizeSession, startTerminal]) + + const clearTerminal = () => { + terminalRef.current?.clear() + } + + return ( +
+
+
+

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

+

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

+
+
+ + +
+
+ +
+ + {shellInfo && ( + <> + {shellInfo.shell} + / + {shellInfo.cwd} + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {status === 'unavailable' ? ( +
+
+ + desktop_windows + +

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

+

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

+
+
+ ) : ( +
+
+ + + + + {t('settings.terminal.windowTitle')} + +
+
+
+ )} +
+ ) +} + +function StatusPill({ status, label }: { status: TerminalStatus; label: string }) { + const color = + status === 'running' + ? 'bg-[var(--color-success)]' + : status === 'error' + ? 'bg-[var(--color-error)]' + : status === 'starting' + ? 'bg-[var(--color-warning)]' + : 'bg-[var(--color-text-tertiary)]' + + return ( + + + {label} + + ) +} diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 89865566..fc2e8ef6 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -34,6 +34,7 @@ export type SettingsTab = | 'general' | 'adapters' | 'install' + | 'terminal' | 'mcp' | 'agents' | 'skills' diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 57f11120..ee4744da 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "@xterm/xterm/css/xterm.css"; @plugin "@tailwindcss/typography"; /* ─── Self-hosted fonts (no Google CDN dependency) ────────────── */ @@ -94,6 +95,19 @@ --line-numbers-font-size: 11px; } +.settings-terminal-host .xterm { + height: 100%; + padding: 0; +} + +.settings-terminal-host .xterm-viewport { + scrollbar-color: rgba(215, 210, 208, 0.35) transparent; +} + +.settings-terminal-host .xterm-screen { + outline: none; +} + /* ─── Tailwind Theme Override (matches prototype design system) ─── */ @theme { --color-primary: #8F482F;