diff --git a/desktop/bun.lock b/desktop/bun.lock index 52d20da9..13c4092d 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", @@ -470,6 +472,10 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/desktop/package.json b/desktop/package.json index b43d7818..7cd33e83 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -18,6 +18,8 @@ "dependencies": { "@tailwindcss/typography": "^0.5.19", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", "lucide-react": "^0.469.0", "marked": "^15.0.7", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 98b02845..8933a60e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -309,6 +309,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.44" @@ -325,6 +331,8 @@ dependencies = [ name = "claude-code-desktop" version = "0.1.5" dependencies = [ + "anyhow", + "portable-pty", "serde", "serde_json", "tauri", @@ -675,6 +683,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -722,7 +736,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -792,6 +806,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1771,6 +1796,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2009,6 +2040,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2533,6 +2576,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3291,6 +3355,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3354,6 +3429,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5242,6 +5333,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 03f749e0..c1feff6a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,3 +18,5 @@ tauri-plugin-process = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +portable-pty = "0.9.0" +anyhow = "1.0.102" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7671fce4..cdfa5a5e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -16,6 +16,13 @@ use tauri_plugin_shell::{ ShellExt, }; +mod terminal; + +use terminal::{ + terminal_close, terminal_resize, terminal_start_session, terminal_write, stop_all_sessions, + TerminalState, +}; + #[derive(Default)] struct ServerState(Mutex); @@ -298,13 +305,18 @@ pub fn run() { let builder = tauri::Builder::default() .manage(ServerState::default()) .manage(AdapterState::default()) + .manage(TerminalState::default()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_server_url, - restart_adapters_sidecar + restart_adapters_sidecar, + terminal_start_session, + terminal_write, + terminal_resize, + terminal_close ]); // macOS: native menu bar (traffic-light overlay style) @@ -403,6 +415,9 @@ pub fn run() { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); + if let Some(state) = app_handle.try_state::() { + stop_all_sessions(&state); + } } }); } diff --git a/desktop/src-tauri/src/terminal.rs b/desktop/src-tauri/src/terminal.rs new file mode 100644 index 00000000..af01943b --- /dev/null +++ b/desktop/src-tauri/src/terminal.rs @@ -0,0 +1,522 @@ +use std::{ + collections::HashMap, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::Mutex, + thread, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Context, Result}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; + +const TERMINAL_EVENT: &str = "desktop-terminal-event"; + +pub struct TerminalSession { + master: Box, + writer: Box, + child: Box, + cleanup_paths: Vec, +} + +#[derive(Default)] +pub struct TerminalState(pub Mutex>); + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalStartResponse { + pub session_id: String, + pub shell: String, + pub cwd: String, + pub explicit_command_name: String, + pub docs_command_name: String, +} + +#[derive(Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TerminalEventPayload { + Output { session_id: String, data: String }, + Exit { session_id: String, exit_code: Option }, +} + +#[tauri::command] +pub fn terminal_start_session( + app: AppHandle, + state: State<'_, TerminalState>, + cwd: Option, +) -> Result { + start_session(&app, &state, cwd).map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn terminal_write( + state: State<'_, TerminalState>, + session_id: String, + data: String, +) -> Result<(), String> { + let mut guard = state + .0 + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = guard + .get_mut(&session_id) + .ok_or_else(|| format!("terminal session not found: {session_id}"))?; + + session + .writer + .write_all(data.as_bytes()) + .and_then(|_| session.writer.flush()) + .map_err(|err| format!("write terminal input: {err}")) +} + +#[tauri::command] +pub fn terminal_resize( + state: State<'_, TerminalState>, + session_id: String, + cols: u16, + rows: u16, +) -> Result<(), String> { + if cols == 0 || rows == 0 { + return Ok(()); + } + + let mut guard = state + .0 + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + let session = guard + .get_mut(&session_id) + .ok_or_else(|| format!("terminal session not found: {session_id}"))?; + + session + .master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("resize terminal: {err}")) +} + +#[tauri::command] +pub fn terminal_close( + state: State<'_, TerminalState>, + session_id: String, +) -> Result<(), String> { + let session = { + let mut guard = state + .0 + .lock() + .map_err(|_| "terminal state is unavailable".to_string())?; + guard.remove(&session_id) + }; + + if let Some(session) = session { + thread::spawn(move || terminate_terminal_session(session)); + } + + Ok(()) +} + +pub fn stop_all_sessions(state: &State<'_, TerminalState>) { + let sessions = if let Ok(mut guard) = state.0.lock() { + guard.drain().map(|(_, session)| session).collect::>() + } else { + Vec::new() + }; + + for session in sessions { + terminate_terminal_session(session); + } +} + +fn cleanup_terminal_session(session: &mut TerminalSession) { + for path in session.cleanup_paths.drain(..) { + let _ = fs::remove_file(path); + } +} + +fn terminate_terminal_session(mut session: TerminalSession) { + let _ = session.child.kill(); + let _ = session.child.wait(); + cleanup_terminal_session(&mut session); +} + +fn start_session( + app: &AppHandle, + state: &State<'_, TerminalState>, + cwd: Option, +) -> Result { + let requested_cwd = resolve_terminal_cwd(cwd)?; + let session_id = build_terminal_session_id(); + let cli_binary = resolve_sidecar_binary_path(app)?; + let app_root = resolve_app_root()?; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .context("open pty")?; + + let shell_bootstrap = build_shell_bootstrap( + &session_id, + &requested_cwd, + &cli_binary, + &app_root, + )?; + + let mut cmd = CommandBuilder::new(&shell_bootstrap.program); + cmd.cwd(&requested_cwd); + for arg in &shell_bootstrap.args { + cmd.arg(arg); + } + for (key, value) in &shell_bootstrap.env { + cmd.env(key, value); + } + + let reader = pair + .master + .try_clone_reader() + .context("clone pty reader")?; + let writer = pair.master.take_writer().context("take pty writer")?; + let child = pair + .slave + .spawn_command(cmd) + .context("spawn shell in pty")?; + + let output_session_id = session_id.clone(); + let output_app = app.clone(); + thread::spawn(move || stream_terminal_output(output_app, output_session_id, reader)); + + let mut guard = state + .0 + .lock() + .map_err(|_| anyhow!("terminal state is unavailable"))?; + guard.insert( + session_id.clone(), + TerminalSession { + master: pair.master, + writer, + child, + cleanup_paths: shell_bootstrap.cleanup_paths, + }, + ); + + Ok(TerminalStartResponse { + session_id, + shell: shell_bootstrap.display_name, + cwd: requested_cwd.to_string_lossy().to_string(), + explicit_command_name: "claude-haha".to_string(), + docs_command_name: "claude".to_string(), + }) +} + +fn stream_terminal_output( + app: AppHandle, + session_id: String, + mut reader: Box, +) { + let mut buffer = [0_u8; 8192]; + + loop { + match reader.read(&mut buffer) { + Ok(0) => { + let _ = app.emit( + TERMINAL_EVENT, + TerminalEventPayload::Exit { + session_id: session_id.clone(), + exit_code: None, + }, + ); + break; + } + Ok(read_bytes) => { + let data = String::from_utf8_lossy(&buffer[..read_bytes]).to_string(); + let _ = app.emit( + TERMINAL_EVENT, + TerminalEventPayload::Output { + session_id: session_id.clone(), + data, + }, + ); + } + Err(_) => { + let _ = app.emit( + TERMINAL_EVENT, + TerminalEventPayload::Exit { + session_id: session_id.clone(), + exit_code: None, + }, + ); + break; + } + } + } +} + +struct ShellBootstrap { + program: String, + args: Vec, + env: Vec<(String, String)>, + cleanup_paths: Vec, + display_name: String, +} + +fn build_shell_bootstrap( + session_id: &str, + cwd: &Path, + cli_binary: &Path, + app_root: &Path, +) -> Result { + #[cfg(target_os = "windows")] + { + let bootstrap = build_powershell_bootstrap(cwd, cli_binary, app_root); + return Ok(ShellBootstrap { + program: "powershell.exe".to_string(), + args: vec![ + "-NoLogo".to_string(), + "-NoExit".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + bootstrap, + ], + env: vec![ + ("TERM".to_string(), "xterm-256color".to_string()), + ("CLAUDE_APP_ROOT".to_string(), app_root.to_string_lossy().to_string()), + ], + cleanup_paths: Vec::new(), + display_name: "PowerShell".to_string(), + }); + } + + #[cfg(not(target_os = "windows"))] + { + let shell_path = resolve_unix_shell()?; + let rc_path = write_bash_rc_file(session_id, cwd, cli_binary, app_root)?; + return Ok(ShellBootstrap { + program: shell_path.to_string_lossy().to_string(), + args: vec![ + "--noprofile".to_string(), + "--rcfile".to_string(), + rc_path.to_string_lossy().to_string(), + "-i".to_string(), + ], + env: vec![ + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + ("CLAUDE_APP_ROOT".to_string(), app_root.to_string_lossy().to_string()), + ], + cleanup_paths: vec![rc_path], + display_name: "bash".to_string(), + }) + } +} + +#[cfg(not(target_os = "windows"))] +fn resolve_unix_shell() -> Result { + for candidate in ["/bin/bash", "/usr/bin/bash"] { + let path = PathBuf::from(candidate); + if path.exists() { + return Ok(path); + } + } + + Err(anyhow!("bash is not available on this machine")) +} + +#[cfg(not(target_os = "windows"))] +fn write_bash_rc_file( + session_id: &str, + cwd: &Path, + cli_binary: &Path, + app_root: &Path, +) -> Result { + let rc_path = std::env::temp_dir().join(format!("cc-haha-terminal-{session_id}.bashrc")); + let home_rc = std::env::var("HOME") + .ok() + .map(PathBuf::from) + .map(|home| home.join(".bashrc")); + + let source_user_rc = if let Some(path) = home_rc.filter(|candidate| candidate.exists()) { + format!("source {} >/dev/null 2>&1 || true\n", quote_for_bash(&path.to_string_lossy())) + } else { + String::new() + }; + + let cli_path = quote_for_bash(&cli_binary.to_string_lossy()); + let app_root_path = quote_for_bash(&app_root.to_string_lossy()); + let cwd_path = quote_for_bash(&cwd.to_string_lossy()); + + let rc_content = format!( + "{source_user_rc}export CLAUDE_APP_ROOT={app_root_path}\n\ +export PS1='\\w $ '\n\ +function claude-haha() {{\n {cli_path} cli --app-root \"$CLAUDE_APP_ROOT\" \"$@\"\n}}\n\ +function claude() {{\n claude-haha \"$@\"\n}}\n\ +cd {cwd_path}\n" + ); + + fs::write(&rc_path, rc_content).context("write terminal rc file")?; + Ok(rc_path) +} + +#[cfg(target_os = "windows")] +fn build_powershell_bootstrap(cwd: &Path, cli_binary: &Path, app_root: &Path) -> String { + let cli = quote_for_powershell(&cli_binary.to_string_lossy()); + let root = quote_for_powershell(&app_root.to_string_lossy()); + let cwd_value = quote_for_powershell(&cwd.to_string_lossy()); + + format!( + "$env:CLAUDE_APP_ROOT = '{root}'; \ +function global:claude-haha {{ & '{cli}' cli --app-root $env:CLAUDE_APP_ROOT @Args }}; \ +function global:claude {{ claude-haha @Args }}; \ +Set-Location -LiteralPath '{cwd_value}'" + ) +} + +fn resolve_terminal_cwd(requested: Option) -> Result { + let path = requested + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(default_home_dir); + + if !path.exists() { + return Err(anyhow!( + "terminal working directory does not exist: {}", + path.to_string_lossy() + )); + } + + if !path.is_dir() { + return Err(anyhow!( + "terminal working directory is not a directory: {}", + path.to_string_lossy() + )); + } + + Ok(path) +} + +fn default_home_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("C:\\")) + } + + #[cfg(not(target_os = "windows"))] + { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")) + } +} + +fn resolve_app_root() -> Result { + let exe = std::env::current_exe().context("resolve current exe path")?; + let dir = exe + .parent() + .ok_or_else(|| anyhow!("current exe has no parent dir"))?; + Ok(dir.to_path_buf()) +} + +fn resolve_sidecar_binary_path(_app: &AppHandle) -> Result { + let current_exe = std::env::current_exe().context("resolve current exe path")?; + let current_dir = current_exe + .parent() + .ok_or_else(|| anyhow!("current exe has no parent dir"))?; + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let binaries_dir = manifest_dir.join("binaries"); + + for candidate in sidecar_name_candidates() { + let sibling = current_dir.join(&candidate); + if sibling.exists() { + return Ok(sibling); + } + + let dev_binary = binaries_dir.join(&candidate); + if dev_binary.exists() { + return Ok(dev_binary); + } + } + + Err(anyhow!( + "could not locate bundled sidecar near {} or {}", + current_dir.to_string_lossy(), + binaries_dir.to_string_lossy() + )) +} + +fn sidecar_name_candidates() -> Vec { + let mut candidates = vec!["claude-sidecar".to_string()]; + let triple_name = format!("claude-sidecar-{}", current_target_triple()); + candidates.push(triple_name); + + #[cfg(target_os = "windows")] + { + let with_exe = candidates + .iter() + .map(|name| format!("{name}.exe")) + .collect::>(); + candidates.extend(with_exe); + } + + candidates +} + +fn current_target_triple() -> &'static str { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + "aarch64-apple-darwin" + } + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + "x86_64-apple-darwin" + } + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + "x86_64-pc-windows-msvc" + } + + #[cfg(all(target_os = "windows", target_arch = "aarch64"))] + { + "aarch64-pc-windows-msvc" + } + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + "x86_64-unknown-linux-gnu" + } + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + "aarch64-unknown-linux-gnu" + } +} + +fn build_terminal_session_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + format!("terminal-{millis}") +} + +fn quote_for_bash(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(target_os = "windows")] +fn quote_for_powershell(value: &str) -> String { + value.replace('\'', "''") +} diff --git a/desktop/src/__tests__/terminalPanel.test.tsx b/desktop/src/__tests__/terminalPanel.test.tsx new file mode 100644 index 00000000..622ca2b9 --- /dev/null +++ b/desktop/src/__tests__/terminalPanel.test.tsx @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { TerminalPanel } from '../components/settings/TerminalPanel' +import { useSettingsStore } from '../stores/settingsStore' + +describe('TerminalPanel', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('shows bundled CLI guidance and browser-runtime fallback text', () => { + render() + + expect(screen.getByText('Setup Terminal')).toBeInTheDocument() + expect( + screen.getByText(/if you already have the official Claude Code installed/i), + ).toBeInTheDocument() + expect( + screen.getByText(/available only inside the packaged Tauri desktop runtime/i), + ).toBeInTheDocument() + expect(screen.getByText('Copy `claude-haha`')).toBeInTheDocument() + expect(screen.queryByText('Working directory')).not.toBeInTheDocument() + expect(screen.queryByText('Restart shell')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/settings/TerminalPanel.restart.test.tsx b/desktop/src/components/settings/TerminalPanel.restart.test.tsx new file mode 100644 index 00000000..2c23736c --- /dev/null +++ b/desktop/src/components/settings/TerminalPanel.restart.test.tsx @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { useSettingsStore } from '../../stores/settingsStore' + +const terminalMocks = vi.hoisted(() => ({ + startDesktopTerminal: vi.fn(), + closeDesktopTerminal: vi.fn(), + resizeDesktopTerminal: vi.fn().mockResolvedValue(undefined), + writeDesktopTerminal: vi.fn().mockResolvedValue(undefined), + listenDesktopTerminalEvents: vi.fn().mockResolvedValue(() => {}), +})) + +vi.mock('../../lib/desktopRuntime', () => ({ + isTauriRuntime: () => true, +})) + +vi.mock('../../lib/settingsTerminal', () => ({ + startDesktopTerminal: terminalMocks.startDesktopTerminal, + closeDesktopTerminal: terminalMocks.closeDesktopTerminal, + resizeDesktopTerminal: terminalMocks.resizeDesktopTerminal, + writeDesktopTerminal: terminalMocks.writeDesktopTerminal, + listenDesktopTerminalEvents: terminalMocks.listenDesktopTerminalEvents, +})) + +class MockTerminal { + cols = 100 + rows = 24 + + loadAddon() {} + + open() {} + + focus() {} + + clear() {} + + write() {} + + onData() { + return { dispose() {} } + } + + dispose() {} +} + +class MockFitAddon { + fit() {} +} + +vi.mock('@xterm/xterm', () => ({ + Terminal: MockTerminal, +})) + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: MockFitAddon, +})) + +import { TerminalPanel } from './TerminalPanel' + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +describe('TerminalPanel restart flow', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ) + + terminalMocks.startDesktopTerminal.mockReset() + terminalMocks.closeDesktopTerminal.mockReset() + terminalMocks.resizeDesktopTerminal.mockClear() + terminalMocks.writeDesktopTerminal.mockClear() + terminalMocks.listenDesktopTerminalEvents.mockClear() + + terminalMocks.startDesktopTerminal + .mockResolvedValueOnce({ + sessionId: 'session-1', + shell: 'bash', + cwd: '/Users/nanmi', + explicitCommandName: 'claude-haha', + docsCommandName: 'claude', + }) + .mockResolvedValueOnce({ + sessionId: 'session-2', + shell: 'bash', + cwd: '/tmp/restarted', + explicitCommandName: 'claude-haha', + docsCommandName: 'claude', + }) + }) + + it('switches to the new session without waiting for old close to finish', async () => { + const oldClose = createDeferred() + terminalMocks.closeDesktopTerminal.mockImplementation((sessionId: string) => { + if (sessionId === 'session-1') return oldClose.promise + return Promise.resolve() + }) + + render() + + await screen.findByText('Shell: bash · Working directory: /Users/nanmi') + + fireEvent.click(screen.getByRole('button', { name: 'Restart shell' })) + + await waitFor(() => + expect( + screen.getByText('Shell: bash · Working directory: /tmp/restarted'), + ).toBeInTheDocument(), + ) + expect(terminalMocks.closeDesktopTerminal).toHaveBeenCalledWith('session-1') + + oldClose.resolve() + }) +}) diff --git a/desktop/src/components/settings/TerminalPanel.tsx b/desktop/src/components/settings/TerminalPanel.tsx new file mode 100644 index 00000000..d582476f --- /dev/null +++ b/desktop/src/components/settings/TerminalPanel.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import '@xterm/xterm/css/xterm.css' +import { Button } from '../shared/Button' +import { useTranslation } from '../../i18n' +import { useUIStore } from '../../stores/uiStore' +import { isTauriRuntime } from '../../lib/desktopRuntime' +import { + closeDesktopTerminal, + listenDesktopTerminalEvents, + resizeDesktopTerminal, + startDesktopTerminal, + writeDesktopTerminal, + type DesktopTerminalStart, +} from '../../lib/settingsTerminal' + +type TerminalLike = { + clear(): void + dispose(): void + focus(): void + write(data: string): void + onData(listener: (data: string) => void): { dispose(): void } + cols: number + rows: number +} + +type FitAddonLike = { + fit(): void +} + +function readThemeVar(name: string, fallback: string) { + if (typeof window === 'undefined') return fallback + const value = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() + return value || fallback +} + +export function TerminalPanel() { + const t = useTranslation() + const addToast = useUIStore((s) => s.addToast) + const [session, setSession] = useState(null) + const [launchError, setLaunchError] = useState(null) + const [isLaunching, setIsLaunching] = useState(false) + const [exitCode, setExitCode] = useState(null) + const [terminalReady, setTerminalReady] = useState(false) + const [eventsReady, setEventsReady] = useState(false) + const hostRef = useRef(null) + const terminalRef = useRef(null) + const fitRef = useRef(null) + const sessionIdRef = useRef(null) + const pendingOutputRef = useRef([]) + const autoStartedRef = useRef(false) + const isDesktop = isTauriRuntime() + + const focusTerminal = () => { + terminalRef.current?.focus() + } + + const closeSessionInBackground = (sessionId: string | null) => { + if (!sessionId) return + void closeDesktopTerminal(sessionId).catch((error) => { + setLaunchError(error instanceof Error ? error.message : String(error)) + }) + } + + const flushBufferedOutput = () => { + if (!terminalRef.current || pendingOutputRef.current.length === 0) return + for (const chunk of pendingOutputRef.current) { + terminalRef.current.write(chunk) + } + pendingOutputRef.current = [] + } + + useEffect(() => { + if (!isDesktop || !hostRef.current || terminalRef.current) return + + let disposed = false + let cleanup = () => {} + + void (async () => { + const [{ Terminal }, { FitAddon }] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + ]) + if (disposed || !hostRef.current) return + + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: + '"SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Mono", ui-monospace, monospace', + fontSize: 12.5, + lineHeight: 1.35, + scrollback: 5000, + theme: { + background: readThemeVar('--color-terminal-bg', '#121212'), + foreground: readThemeVar('--color-terminal-fg', '#d4d4d4'), + cursor: readThemeVar('--color-brand', '#d97757'), + cursorAccent: readThemeVar('--color-surface', '#ffffff'), + selectionBackground: 'rgba(217, 119, 87, 0.22)', + black: '#1f1f1f', + red: '#ff6d67', + green: '#7ef18a', + yellow: '#f8c55f', + blue: '#7aa2f7', + magenta: '#c792ea', + cyan: '#7fd1b9', + white: '#d7d2d0', + brightBlack: '#6c6763', + brightRed: '#ff8e88', + brightGreen: '#96f5a0', + brightYellow: '#ffd67c', + brightBlue: '#98b8ff', + brightMagenta: '#ddb2ff', + brightCyan: '#9be7d1', + brightWhite: '#f7f2ef', + }, + }) + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + terminal.open(hostRef.current) + terminal.focus() + terminalRef.current = terminal + fitRef.current = fitAddon + setTerminalReady(true) + flushBufferedOutput() + + const scheduleResize = () => { + if (!fitRef.current || !terminalRef.current || !sessionIdRef.current) return + fitRef.current.fit() + const cols = terminalRef.current.cols + const rows = terminalRef.current.rows + if (cols > 0 && rows > 0) { + void resizeDesktopTerminal(sessionIdRef.current, cols, rows).catch(() => null) + } + } + + const dataDisposable = terminal.onData((data) => { + const currentSessionId = sessionIdRef.current + if (!currentSessionId) return + void writeDesktopTerminal(currentSessionId, data).catch((error) => { + setLaunchError( + error instanceof Error ? error.message : String(error), + ) + }) + }) + + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => scheduleResize()) + : null + resizeObserver?.observe(hostRef.current) + hostRef.current.addEventListener('mousedown', focusTerminal) + + setTimeout(scheduleResize, 0) + + cleanup = () => { + dataDisposable.dispose() + resizeObserver?.disconnect() + hostRef.current?.removeEventListener('mousedown', focusTerminal) + terminal.dispose() + terminalRef.current = null + fitRef.current = null + setTerminalReady(false) + } + })() + + return () => { + disposed = true + cleanup() + } + }, [isDesktop]) + + useEffect(() => { + if (!isDesktop) return + + let cancelled = false + let unlisten: (() => void) | undefined + + void listenDesktopTerminalEvents((event) => { + if (cancelled || event.sessionId !== sessionIdRef.current) return + if (event.type === 'output') { + if (terminalRef.current) { + terminalRef.current.write(event.data) + } else { + pendingOutputRef.current.push(event.data) + } + return + } + + setExitCode(event.exitCode ?? null) + if (event.exitCode !== null && event.exitCode !== undefined) { + terminalRef.current?.write( + `\r\n${t('settings.terminal.exitPrefix')} ${event.exitCode}\r\n`, + ) + } + }).then((dispose) => { + unlisten = dispose + setEventsReady(true) + }) + + return () => { + cancelled = true + setEventsReady(false) + unlisten?.() + } + }, [isDesktop, t]) + + const startSession = async () => { + if (!isDesktop) return + + setIsLaunching(true) + setLaunchError(null) + setExitCode(null) + + const previousSessionId = sessionIdRef.current + + try { + const started = await startDesktopTerminal() + sessionIdRef.current = started.sessionId + setSession(started) + terminalRef.current?.clear() + terminalRef.current?.focus() + fitRef.current?.fit() + if (terminalRef.current?.cols && terminalRef.current?.rows) { + await resizeDesktopTerminal( + started.sessionId, + terminalRef.current.cols, + terminalRef.current.rows, + ).catch(() => null) + } + if (previousSessionId && previousSessionId !== started.sessionId) { + closeSessionInBackground(previousSessionId) + } + } catch (error) { + setLaunchError(error instanceof Error ? error.message : String(error)) + } finally { + setIsLaunching(false) + } + } + + useEffect(() => { + if (!isDesktop || !terminalReady || !eventsReady || autoStartedRef.current) return + autoStartedRef.current = true + void startSession() + + return () => { + autoStartedRef.current = false + const existingSessionId = sessionIdRef.current + sessionIdRef.current = null + if (existingSessionId) { + closeSessionInBackground(existingSessionId) + } + } + }, [eventsReady, isDesktop, terminalReady]) + + const explicitCommand = session?.explicitCommandName || 'claude-haha' + const docsCommand = session?.docsCommandName || 'claude' + const exampleCommands = useMemo( + () => [ + `${docsCommand} plugin install skill-creator@claude-plugins-official --scope user`, + `${explicitCommand} mcp add docs --transport http https://example.com/mcp`, + ], + [docsCommand, explicitCommand], + ) + + const handleCopy = async (value: string) => { + try { + await navigator.clipboard.writeText(value) + addToast({ + type: 'success', + message: t('settings.terminal.copied'), + }) + } catch (error) { + addToast({ + type: 'error', + message: + error instanceof Error ? error.message : t('settings.terminal.copyFailed'), + }) + } + } + + return ( +
+
+
+
+
+ {t('settings.terminal.eyebrow')} +
+

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

+

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

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

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

+
+ +
+ + +
+
+ +
+ {exampleCommands.map((command) => ( + + ))} +
+
+
+ +
+ {!isDesktop ? ( +
+ {t('settings.terminal.runtimeOnly')} +
+ ) : ( +
+
+
+
+ {t('settings.terminal.sessionTitle')} +
+
+ {session + ? t('settings.terminal.sessionMeta', { + shell: session.shell, + cwd: session.cwd, + }) + : t('settings.terminal.sessionPending')} +
+
+ {exitCode !== null ? ( + + {t('settings.terminal.exitBadge', { code: String(exitCode) })} + + ) : null} +
+ + {launchError ? ( +
+ {launchError} +
+ ) : null} + +
+
+
+
+
+ + {session?.cwd || t('settings.terminal.sessionPending')} + +
+
+
+
+ )} +
+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 51999a77..e4eb4615 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -51,6 +51,7 @@ export const en = { 'settings.tab.providers': 'Providers', 'settings.tab.permissions': 'Permissions', 'settings.tab.general': 'General', + 'settings.tab.terminal': 'Terminal', 'settings.tab.install': 'Install', 'settings.tab.skills': 'Skills', 'settings.tab.mcp': 'MCP', @@ -320,6 +321,27 @@ export const en = { 'settings.install.createFailed': 'Failed to create installer session', 'settings.install.refreshDone': 'Plugin, MCP, and skill state refreshed', 'settings.install.newConversationReady': 'A fresh installer conversation is ready; your next request will start a new install context.', + 'settings.terminal.eyebrow': 'Bundled CLI Terminal', + 'settings.terminal.title': 'Setup Terminal', + 'settings.terminal.description': 'Open a real shell inside Settings for plugin, MCP, and skill setup. This terminal wires the bundled desktop CLI into the shell so install commands can run without requiring a separate global Claude CLI.', + 'settings.terminal.restart': 'Restart shell', + 'settings.terminal.clear': 'Clear screen', + 'settings.terminal.docsTitle': 'Docs command compatibility', + 'settings.terminal.docsBody': 'If you already have the official Claude Code installed, you can keep using `{docsCommand} ...` commands. If not, use the bundled CLI here instead: `{explicitCommand} ...`.', + 'settings.terminal.copyDocsCommand': 'Copy `{name}`', + 'settings.terminal.copyExplicitCommand': 'Copy `{name}`', + 'settings.terminal.copied': 'Command copied', + 'settings.terminal.copyFailed': 'Failed to copy command', + 'settings.terminal.sessionTitle': 'Interactive shell', + 'settings.terminal.sessionMeta': 'Shell: {shell} · Working directory: {cwd}', + 'settings.terminal.sessionPending': 'Launching shell...', + 'settings.terminal.exitPrefix': 'Shell exited with code', + 'settings.terminal.exitBadge': 'Exit {code}', + 'settings.terminal.runtimeOnly': 'The embedded terminal is available only inside the packaged Tauri desktop runtime.', + 'settings.terminal.contextTitle': 'Working directory', + 'settings.terminal.contextAuto': 'Default context', + 'settings.terminal.contextHint': 'Choose where the shell should start before restarting it. This is useful when an install command needs project-local files or a local MCP config target.', + 'settings.terminal.contextActive': 'Current shell directory: {path}', // Settings > Skills 'settings.skills.title': 'Installed Skills', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 54295417..8fbe6fc0 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -53,6 +53,7 @@ export const zh: Record = { 'settings.tab.providers': '服务商', 'settings.tab.permissions': '权限', 'settings.tab.general': '通用', + 'settings.tab.terminal': '终端', 'settings.tab.install': '安装中心', 'settings.tab.skills': '技能', 'settings.tab.mcp': 'MCP', @@ -322,6 +323,27 @@ export const zh: Record = { 'settings.install.createFailed': '创建安装会话失败', 'settings.install.refreshDone': '已刷新插件、MCP 和技能状态', 'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。', + 'settings.terminal.eyebrow': '内置 CLI 终端', + 'settings.terminal.title': '安装与配置终端', + 'settings.terminal.description': '在设置页里直接打开一个真实 shell,用来安装 plugin、MCP 和 skill。这个终端会把桌面端内置 CLI 注入进去,不依赖用户额外安装全局 Claude CLI。', + 'settings.terminal.restart': '重启 shell', + 'settings.terminal.clear': '清空屏幕', + 'settings.terminal.docsTitle': '安装命令兼容提示', + 'settings.terminal.docsBody': '如果你已经安装了官方 Claude Code,可以继续使用 `{docsCommand} ...` 命令;如果没有安装,就使用我们打包的 CLI,也就是 `{explicitCommand} ...`。', + 'settings.terminal.copyDocsCommand': '复制 `{name}`', + 'settings.terminal.copyExplicitCommand': '复制 `{name}`', + 'settings.terminal.copied': '命令已复制', + 'settings.terminal.copyFailed': '复制命令失败', + 'settings.terminal.sessionTitle': '交互式 shell', + 'settings.terminal.sessionMeta': 'Shell:{shell} · 工作目录:{cwd}', + 'settings.terminal.sessionPending': '正在启动 shell...', + 'settings.terminal.exitPrefix': 'Shell 退出,退出码', + 'settings.terminal.exitBadge': '退出 {code}', + 'settings.terminal.runtimeOnly': '这个内嵌终端只会在打包后的 Tauri 桌面运行时里可用。', + 'settings.terminal.contextTitle': '启动目录', + 'settings.terminal.contextAuto': '默认上下文', + 'settings.terminal.contextHint': '如果安装命令需要项目本地文件,或者要写 local/project 级 MCP 配置,可以先在这里切目录,再重启 shell。', + 'settings.terminal.contextActive': '当前 shell 目录:{path}', // Settings > Skills 'settings.skills.title': '已安装技能', diff --git a/desktop/src/lib/settingsTerminal.ts b/desktop/src/lib/settingsTerminal.ts new file mode 100644 index 00000000..05dce042 --- /dev/null +++ b/desktop/src/lib/settingsTerminal.ts @@ -0,0 +1,56 @@ +import { isTauriRuntime } from './desktopRuntime' + +export type DesktopTerminalStart = { + sessionId: string + shell: string + cwd: string + explicitCommandName: string + docsCommandName: string +} + +export type DesktopTerminalEvent = + | { type: 'output'; sessionId: string; data: string } + | { type: 'exit'; sessionId: string; exitCode?: number | null } + +export async function startDesktopTerminal(cwd?: string) { + if (!isTauriRuntime()) { + throw new Error('Desktop terminal is only available in the Tauri runtime.') + } + + const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return invoke('terminal_start_session', { + cwd: cwd?.trim() || null, + }) +} + +export async function writeDesktopTerminal(sessionId: string, data: string) { + const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return invoke('terminal_write', { sessionId, data }) +} + +export async function resizeDesktopTerminal( + sessionId: string, + cols: number, + rows: number, +) { + const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return invoke('terminal_resize', { + sessionId, + cols, + rows, + }) +} + +export async function closeDesktopTerminal(sessionId: string) { + const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core') + return invoke('terminal_close', { sessionId }) +} + +export async function listenDesktopTerminalEvents( + handler: (event: DesktopTerminalEvent) => void, +) { + const { listen } = await import(/* @vite-ignore */ '@tauri-apps/api/event') + return listen('desktop-terminal-event', (event) => { + handler(event.payload) + }) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 643d894c..dea53f1b 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -28,6 +28,7 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { InstallCenter } from '../components/settings/InstallCenter' +import { TerminalPanel } from '../components/settings/TerminalPanel' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -50,6 +51,7 @@ export function Settings() { setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('adapters')} /> + setActiveTab('terminal')} /> setActiveTab('install')} /> setActiveTab('mcp')} /> setActiveTab('agents')} /> @@ -68,6 +70,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'general' && } {activeTab === 'adapters' && } + {activeTab === 'terminal' && } {activeTab === 'install' && } {activeTab === 'mcp' && } {activeTab === 'agents' && } @@ -1440,7 +1443,7 @@ function AboutSettings() { return (
{/* Logo + App Name + Version */} - Claude Code Haha + Claude Code Haha

Claude Code Haha

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