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.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-24 09:36:00 +08:00
parent de736c49f7
commit 0c58a9f36f
13 changed files with 1145 additions and 12 deletions

View File

@ -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=="],

View File

@ -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",

View File

@ -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"

View File

@ -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"

View File

@ -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<Option<CommandChild>>);
#[derive(Default)]
struct TerminalState {
next_id: AtomicU32,
sessions: Mutex<HashMap<u32, TerminalSession>>,
}
struct TerminalSession {
master: Box<dyn MasterPty + Send>,
writer: Mutex<Box<dyn std::io::Write + Send>>,
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
}
#[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<String>,
}
#[tauri::command]
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
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<String>,
) -> Result<TerminalSpawnResult, String> {
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::<TerminalState>() {
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<u8>, 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<String, String> {
let mut env: HashMap<String, String> = std::env::vars().collect();
env.extend(login_shell_environment(shell));
ensure_utf8_locale(&mut env);
env
}
fn ensure_utf8_locale(env: &mut HashMap<String, String>) {
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<String, String> {
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<String, String> {
HashMap::new()
}
fn parse_env_block(bytes: &[u8]) -> HashMap<String, String> {
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<String>) -> Result<PathBuf, String> {
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<PathBuf> {
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<u16, String> {
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<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())?
@ -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()

View File

@ -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<T>(command: string, args?: Record<string, unknown>): Promise<T> {
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<T>(command, args)
}
export const terminalApi = {
isAvailable: isTauriRuntime,
spawn(input: { cols: number; rows: number; cwd?: string }) {
return invoke<TerminalSpawnResult>('terminal_spawn', input)
},
write(sessionId: number, data: string) {
return invoke<void>('terminal_write', { sessionId, data })
},
resize(sessionId: number, cols: number, rows: number) {
return invoke<void>('terminal_resize', { sessionId, cols, rows })
},
kill(sessionId: number) {
return invoke<void>('terminal_kill', { sessionId })
},
async onOutput(handler: (payload: TerminalOutputPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
return events.listen<TerminalOutputPayload>('terminal-output', (event) => handler(event.payload))
},
async onExit(handler: (payload: TerminalExitPayload) => void): Promise<Unlisten> {
const events = await import(/* @vite-ignore */ '@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},
}

View File

@ -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',

View File

@ -53,10 +53,26 @@ export const zh: Record<TranslationKey, string> = {
'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 账号',

View File

@ -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() {
<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="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<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')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
@ -69,6 +71,7 @@ export function Settings() {
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'install' && <InstallCenter />}
{activeTab === 'terminal' && <TerminalSettings />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}

View File

@ -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(<TerminalSettings />)
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(<TerminalSettings />)
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(<TerminalSettings />)
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')
})
})

View File

@ -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<TerminalStatus, TranslationKey> = {
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<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
const fitRef = useRef<XTermFitAddon | null>(null)
const sessionIdRef = useRef<number | null>(null)
const unlistenRef = useRef<Array<() => void>>([])
const [status, setStatus] = useState<TerminalStatus>(() => terminalApi.isAvailable() ? 'idle' : 'unavailable')
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full min-h-[620px] flex-col overflow-hidden">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.title')}
</h2>
<p className="mt-0.5 max-w-2xl text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.description')}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={clearTerminal}
disabled={!terminalRef.current}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]">mop</span>
{t('settings.terminal.clear')}
</button>
<button
type="button"
onClick={() => void startTerminal()}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
{t('settings.terminal.restart')}
</button>
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<StatusPill status={status} label={t(STATUS_LABEL_KEYS[status])} />
{shellInfo && (
<>
<span className="font-mono">{shellInfo.shell}</span>
<span className="text-[var(--color-border)]">/</span>
<span className="min-w-0 max-w-full truncate font-mono">{shellInfo.cwd}</span>
</>
)}
</div>
{error && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
)}
{status === 'unavailable' ? (
<div className="flex flex-1 items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-8 text-center">
<div>
<span className="material-symbols-outlined mb-3 block text-[32px] text-[var(--color-text-tertiary)]">
desktop_windows
</span>
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.unavailableTitle')}
</p>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.unavailableBody')}
</p>
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]">
<div className="flex h-8 items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
<span className="ml-2 truncate font-mono text-[11px] text-[var(--color-terminal-muted)]">
{t('settings.terminal.windowTitle')}
</span>
</div>
<div
ref={hostRef}
data-testid="settings-terminal-host"
className="settings-terminal-host h-[calc(100%-2rem)] w-full overflow-hidden p-2"
/>
</div>
)}
</div>
)
}
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 (
<span className="inline-flex h-6 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
{label}
</span>
)
}

View File

@ -34,6 +34,7 @@ export type SettingsTab =
| 'general'
| 'adapters'
| 'install'
| 'terminal'
| 'mcp'
| 'agents'
| 'skills'

View File

@ -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;