mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
de736c49f7
commit
0c58a9f36f
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
101
desktop/src-tauri/Cargo.lock
generated
101
desktop/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
58
desktop/src/api/terminal.ts
Normal file
58
desktop/src/api/terminal.ts
Normal 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))
|
||||
},
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 账号',
|
||||
|
||||
@ -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 />}
|
||||
|
||||
128
desktop/src/pages/TerminalSettings.test.tsx
Normal file
128
desktop/src/pages/TerminalSettings.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
275
desktop/src/pages/TerminalSettings.tsx
Normal file
275
desktop/src/pages/TerminalSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -34,6 +34,7 @@ export type SettingsTab =
|
||||
| 'general'
|
||||
| 'adapters'
|
||||
| 'install'
|
||||
| 'terminal'
|
||||
| 'mcp'
|
||||
| 'agents'
|
||||
| 'skills'
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user