mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add a bundled desktop setup terminal with safer restart handoff
Settings needed a real shell for plugin, MCP, and skill setup without relying on a globally installed Claude CLI. Add an xterm.js terminal backed by portable-pty, wire it into the Tauri desktop runtime, and move shell restart handoff to the new session before old PTY teardown so the UI is less likely to stall behind child shutdown. Constraint: The desktop app must inject the bundled CLI into the shell environment instead of requiring a separate global install Constraint: Restart teardown cannot block the frontend-facing Tauri command path Rejected: Keep terminal setup inside installer chat only | that flow cannot replace an interactive shell Rejected: Wait for old PTY shutdown before adopting the new session | it keeps restart vulnerable to hung child teardown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve new-session handoff before old-session cleanup when changing terminal lifecycle or restart logic Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: Full packaged-app command echo and repeated manual restart behavior still need additional runtime verification
This commit is contained in:
parent
467debcd8b
commit
376e255b6b
@ -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",
|
||||
|
||||
102
desktop/src-tauri/Cargo.lock
generated
102
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,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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<ServerStatus>);
|
||||
|
||||
@ -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::<TerminalState>() {
|
||||
stop_all_sessions(&state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
522
desktop/src-tauri/src/terminal.rs
Normal file
522
desktop/src-tauri/src/terminal.rs
Normal file
@ -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<dyn MasterPty + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send>,
|
||||
cleanup_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TerminalState(pub Mutex<HashMap<String, TerminalSession>>);
|
||||
|
||||
#[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<i32> },
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_start_session(
|
||||
app: AppHandle,
|
||||
state: State<'_, TerminalState>,
|
||||
cwd: Option<String>,
|
||||
) -> Result<TerminalStartResponse, String> {
|
||||
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::<Vec<_>>()
|
||||
} 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<String>,
|
||||
) -> Result<TerminalStartResponse> {
|
||||
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<dyn Read + Send>,
|
||||
) {
|
||||
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<String>,
|
||||
env: Vec<(String, String)>,
|
||||
cleanup_paths: Vec<PathBuf>,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
fn build_shell_bootstrap(
|
||||
session_id: &str,
|
||||
cwd: &Path,
|
||||
cli_binary: &Path,
|
||||
app_root: &Path,
|
||||
) -> Result<ShellBootstrap> {
|
||||
#[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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String>) -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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('\'', "''")
|
||||
}
|
||||
27
desktop/src/__tests__/terminalPanel.test.tsx
Normal file
27
desktop/src/__tests__/terminalPanel.test.tsx
Normal file
@ -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(<TerminalPanel />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
127
desktop/src/components/settings/TerminalPanel.restart.test.tsx
Normal file
127
desktop/src/components/settings/TerminalPanel.restart.test.tsx
Normal file
@ -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<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>((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<void>()
|
||||
terminalMocks.closeDesktopTerminal.mockImplementation((sessionId: string) => {
|
||||
if (sessionId === 'session-1') return oldClose.promise
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TerminalPanel />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
422
desktop/src/components/settings/TerminalPanel.tsx
Normal file
422
desktop/src/components/settings/TerminalPanel.tsx
Normal file
@ -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<DesktopTerminalStart | null>(null)
|
||||
const [launchError, setLaunchError] = useState<string | null>(null)
|
||||
const [isLaunching, setIsLaunching] = useState(false)
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
const [terminalReady, setTerminalReady] = useState(false)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<TerminalLike | null>(null)
|
||||
const fitRef = useRef<FitAddonLike | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
const pendingOutputRef = useRef<string[]>([])
|
||||
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 (
|
||||
<div className="w-full min-w-0 max-w-6xl">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.terminal.eyebrow')}
|
||||
</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.terminal.title')}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{t('settings.terminal.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void startSession()}
|
||||
loading={isLaunching}
|
||||
>
|
||||
{t('settings.terminal.restart')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => terminalRef.current?.clear()}
|
||||
>
|
||||
{t('settings.terminal.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.terminal.docsTitle')}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('settings.terminal.docsBody', {
|
||||
docsCommand,
|
||||
explicitCommand,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleCopy(docsCommand)}
|
||||
>
|
||||
{t('settings.terminal.copyDocsCommand', { name: docsCommand })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleCopy(explicitCommand)}
|
||||
>
|
||||
{t('settings.terminal.copyExplicitCommand', {
|
||||
name: explicitCommand,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{exampleCommands.map((command) => (
|
||||
<button
|
||||
key={command}
|
||||
type="button"
|
||||
onClick={() => void handleCopy(command)}
|
||||
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 font-[var(--font-mono)] text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{command}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
{!isDesktop ? (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-6 text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('settings.terminal.runtimeOnly')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.terminal.sessionTitle')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{session
|
||||
? t('settings.terminal.sessionMeta', {
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
})
|
||||
: t('settings.terminal.sessionPending')}
|
||||
</div>
|
||||
</div>
|
||||
{exitCode !== null ? (
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.terminal.exitBadge', { code: String(exitCode) })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{launchError ? (
|
||||
<div className="mt-4 rounded-xl border border-[var(--color-error)]/25 bg-[var(--color-error)]/8 px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{launchError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-4 py-2.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
|
||||
<span className="ml-2 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-terminal-muted)]">
|
||||
{session?.cwd || t('settings.terminal.sessionPending')}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="h-[560px] min-h-[420px] w-full cursor-text px-2 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -53,6 +53,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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': '已安装技能',
|
||||
|
||||
56
desktop/src/lib/settingsTerminal.ts
Normal file
56
desktop/src/lib/settingsTerminal.ts
Normal file
@ -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<DesktopTerminalStart>('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<DesktopTerminalEvent>('desktop-terminal-event', (event) => {
|
||||
handler(event.payload)
|
||||
})
|
||||
}
|
||||
@ -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<SettingsTab>('providers')
|
||||
@ -50,6 +51,7 @@ export function Settings() {
|
||||
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<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="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
|
||||
<TabButton icon="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
|
||||
<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')} />
|
||||
@ -68,6 +70,7 @@ export function Settings() {
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'terminal' && <TerminalPanel />}
|
||||
{activeTab === 'install' && <InstallCenter />}
|
||||
{activeTab === 'mcp' && <McpSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
@ -1440,7 +1443,7 @@ function AboutSettings() {
|
||||
return (
|
||||
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
|
||||
{/* Logo + App Name + Version */}
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="w-20 h-20 rounded-2xl shadow-md mb-4" />
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="w-20 h-20 mb-4" />
|
||||
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
|
||||
{version && (
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] mt-1">{t('settings.about.version')} {version}</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user