Skip to content

Commit 7b82979

Browse files
gaoyu06claude
andcommitted
feat: multi-backend engines (jucode/codex/claude), shell-env injection, thinking-effort slider
Drive three agent CLIs from the desktop, plus supporting fixes. Files are deeply shared across these areas, so they ship as one buildable commit. Multi-backend architecture: - Generic backend registry + fixed argv templates + option allowlists in src-tauri/src/backend.rs (frontend never passes raw argv); per-session child spawn, send_line, check_backend. - Frontend adapter layer src/lib/backends/: EngineAdapter interface (onStart/translate/encodeOp/caps), jucode passthrough, per-session router, capability gating via a single caps(chat) helper. Backend picker on new session, per-backend status/path in settings, sidebar badges. Codex adapter (app-server JSON-RPC, protocol 2025-06-18): - thread/turn lifecycle, streaming, approvals bridged to the approval card, interrupt, model/list model picker, thread/resume with transcript replay, thread/list resume picker, thread/compact, thread/goal. Claude adapter (stream-json bidirectional): - init/stream_event/tool/result mapping, can_use_tool approvals with native session-scoped "always", set_permission_mode modes, set_model live model switch, ~/.claude session listing + transcript replay (claude_history.rs), /compact, and thinking-effort switching via the /effort slash command (low/medium/high/xhigh/max, switched in place — set_max_thinking_tokens is deprecated). Terminal-environment injection (src-tauri/src/shell_env.rs): - Capture a login-shell snapshot ($SHELL -ilc + env -0, async, 8s timeout, denylist), rebuild backend child env from it (env_clear), plus per-backend custom env vars — so engines behave like in the user's terminal (fixes third-party wrappers like reclaude that inject HTTPS_PROXY / CA vars). Shell-env status + refresh + per-backend env editor in settings. Faster<->Smarter effort slider (src/lib/ui/EffortSlider.svelte): - Replaces the effort dropdown for all effort-capable backends. Fixes: - De-duplicate the replay user echo that arrives after the assistant reply (claude --replay-user-messages) — no longer double-renders a message. svelte-check 0/0, vitest 280, cargo test 73, cargo clippy 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4d59cbb commit 7b82979

42 files changed

Lines changed: 8460 additions & 189 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src-tauri/src/backend.rs

Lines changed: 698 additions & 0 deletions
Large diffs are not rendered by default.

src-tauri/src/claude_history.rs

Lines changed: 436 additions & 0 deletions
Large diffs are not rendered by default.

src-tauri/src/lib.rs

Lines changed: 172 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ use std::sync::{Arc, Mutex};
77
use serde::Serialize;
88
use tauri::{AppHandle, Emitter, Manager};
99

10+
mod backend;
1011
mod browser;
1112
mod capture;
13+
mod claude_history;
14+
mod shell_env;
15+
16+
use backend::BackendKind;
1217

1318
/// One `jucode serve` child process backing a single GUI session.
1419
struct Session {
@@ -30,62 +35,13 @@ struct EventPayload {
3035
}
3136

3237
/// Resolves the `jucode` binary: `JUCODE_BIN` override, then the system-installed
33-
/// CLI on PATH, then a sibling `JuCode-CLI` checkout / in-tree build (dev
34-
/// convenience). The desktop app no longer bundles the engine — it drives
35-
/// whatever `jucode` the user has installed.
38+
/// CLI on PATH, then well-known install dirs, then a sibling `JuCode-CLI`
39+
/// checkout / in-tree build (dev convenience). The desktop app no longer
40+
/// bundles the engine — it drives whatever `jucode` the user has installed.
41+
/// (Resolution now lives in `backend::resolve_backend_bin`, shared with the
42+
/// codex / claude backends.)
3643
fn resolve_bin() -> PathBuf {
37-
// On Windows the engine binary carries an .exe suffix.
38-
let exe = if cfg!(windows) { "jucode.exe" } else { "jucode" };
39-
if let Ok(path) = std::env::var("JUCODE_BIN") {
40-
return PathBuf::from(path);
41-
}
42-
// System install (PATH). A packaged app inherits a minimal PATH (launchd on
43-
// macOS, the desktop session elsewhere), so also probe the usual install
44-
// locations directly.
45-
if let Some(found) = which("jucode") {
46-
return found;
47-
}
48-
// HOME on unix; USERPROFILE on Windows.
49-
let home = std::env::var_os("HOME")
50-
.or_else(|| std::env::var_os("USERPROFILE"))
51-
.map(PathBuf::from)
52-
.unwrap_or_default();
53-
let mut well_known: Vec<PathBuf> = Vec::new();
54-
if cfg!(windows) {
55-
// Per-user installer dir and the npm global prefix.
56-
if let Some(la) = std::env::var_os("LOCALAPPDATA") {
57-
well_known.push(PathBuf::from(la).join("Programs").join("jucode").join(exe));
58-
}
59-
if let Some(ad) = std::env::var_os("APPDATA") {
60-
well_known.push(PathBuf::from(ad).join("npm").join(exe));
61-
}
62-
well_known.push(home.join(".cargo").join("bin").join(exe));
63-
} else {
64-
well_known.push(PathBuf::from("/opt/homebrew/bin/jucode")); // macOS (arm64 Homebrew)
65-
well_known.push(PathBuf::from("/usr/local/bin/jucode")); // macOS (intel) / Linux
66-
well_known.push(home.join(".cargo/bin/jucode"));
67-
well_known.push(home.join(".local/bin/jucode")); // Linux per-user installs
68-
}
69-
for candidate in well_known {
70-
if candidate.is_file() {
71-
return candidate;
72-
}
73-
}
74-
// Dev fallback: the freshly-built engine from the sibling checkout.
75-
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // <repo>/src-tauri
76-
let candidates = [
77-
format!("../../JuCode-CLI/target/debug/{exe}"),
78-
format!("../../JuCode-CLI/target/release/{exe}"),
79-
format!("../../target/debug/{exe}"),
80-
format!("../../target/release/{exe}"),
81-
];
82-
for rel in candidates {
83-
let candidate = manifest.join(rel);
84-
if candidate.exists() {
85-
return candidate;
86-
}
87-
}
88-
PathBuf::from("jucode")
44+
backend::resolve_backend_bin(BackendKind::Jucode, None)
8945
}
9046

9147
/// Working directory the agent operates in. `JUCODE_CWD` override, else the
@@ -140,27 +96,55 @@ fn confine_to_root(path: &Path, root: Option<&Path>) -> Result<PathBuf, String>
14096

14197
/// Spawns a new engine process for `session`. The frontend generates the id and
14298
/// registers its event listener before calling this, so no startup event is lost.
99+
///
100+
/// `backend` selects which agent CLI backs the session (default `"jucode"`,
101+
/// which keeps the historical behavior exactly); `backend_opts` is validated
102+
/// against that backend's fixed option allowlist (see `backend::validate_opts`)
103+
/// — the frontend can never pass raw argv.
143104
#[tauri::command]
144105
fn create_session(
145106
session: String,
146107
cwd: Option<String>,
108+
backend: Option<String>,
109+
backend_opts: Option<serde_json::Value>,
147110
app: AppHandle,
148111
engines: tauri::State<Engines>,
149112
) -> Result<(), String> {
113+
let kind = BackendKind::parse(backend.as_deref().unwrap_or("jucode"))?;
114+
let opts = backend::validate_opts(kind, backend_opts.as_ref())?;
115+
let bin = backend::resolve_backend_bin(kind, opts.bin_override.as_deref());
116+
let args = backend::build_args(kind, &opts);
150117
let dir = cwd
151118
.map(PathBuf::from)
152119
.filter(|p| p.is_dir())
153120
.unwrap_or_else(resolve_cwd);
154-
let mut child = Command::new(resolve_bin())
155-
.arg("serve")
156-
// Lets the engine enable desktop-only tools (e.g. browser_open).
157-
.env("JUCODE_DESKTOP", "1")
121+
let mut cmd = Command::new(bin);
122+
cmd.args(&args)
158123
.current_dir(dir)
159124
.stdin(Stdio::piped())
160-
.stdout(Stdio::piped())
161-
.stderr(Stdio::inherit())
162-
.spawn()
163-
.map_err(|error| format!("failed to start jucode serve: {error}"))?;
125+
.stdout(Stdio::piped());
126+
if kind == BackendKind::Jucode {
127+
// jucode's stderr stays inherited (visible in the app's own stderr),
128+
// exactly as before multi-backend support.
129+
cmd.stderr(Stdio::inherit());
130+
} else {
131+
// codex / claude diagnostics matter to their adapters — pipe stderr and
132+
// forward each line to the webview as a distinct `{__stderr: …}` payload.
133+
cmd.stderr(Stdio::piped());
134+
}
135+
// 终端等价环境:快照可用则从零重建子进程环境(见 shell_env.rs),
136+
// JUCODE_DESKTOP 让引擎启用桌面专属工具(如 browser_open),协议关键、
137+
// 最后断言不可被用户自定义覆盖。
138+
let explicit: &[(&str, &str)] = if kind == BackendKind::Jucode {
139+
&[("JUCODE_DESKTOP", "1")]
140+
} else {
141+
&[]
142+
};
143+
shell_env::apply_to_command(&mut cmd, opts.use_shell_env, explicit, &opts.env);
144+
let mut child = cmd.spawn().map_err(|error| match kind {
145+
BackendKind::Jucode => format!("failed to start jucode serve: {error}"),
146+
_ => format!("failed to start {} backend: {error}", kind.bin_name()),
147+
})?;
164148

165149
let stdout = child
166150
.stdout
@@ -171,6 +155,32 @@ fn create_session(
171155
.take()
172156
.ok_or_else(|| "failed to capture child stdin".to_string())?;
173157

158+
// Piped stderr (codex / claude): forward lines as {"__stderr": "<line>"}
159+
// agent-event payloads so adapters can surface diagnostics.
160+
if let Some(stderr) = child.stderr.take() {
161+
let id = session.clone();
162+
let handle = app.clone();
163+
std::thread::spawn(move || {
164+
let reader = BufReader::new(stderr);
165+
for line in reader.lines() {
166+
match line {
167+
Ok(line) if !line.trim().is_empty() => {
168+
let data = serde_json::json!({ "__stderr": line }).to_string();
169+
let _ = handle.emit(
170+
"agent-event",
171+
EventPayload {
172+
session: id.clone(),
173+
data,
174+
},
175+
);
176+
}
177+
Ok(_) => {}
178+
Err(_) => break,
179+
}
180+
}
181+
});
182+
}
183+
174184
let id = session.clone();
175185
let handle = app.clone();
176186
std::thread::spawn(move || {
@@ -207,20 +217,15 @@ fn create_session(
207217
Ok(())
208218
}
209219

210-
#[tauri::command]
211-
fn send_op(
212-
session: String,
213-
op: serde_json::Value,
214-
engines: tauri::State<Engines>,
215-
) -> Result<(), String> {
220+
/// Writes one raw line (a single protocol frame) to a session child's stdin.
221+
fn write_line(engines: &Engines, session: &str, line: &str) -> Result<(), String> {
216222
let target = engines
217223
.sessions
218224
.lock()
219225
.map_err(|e| format!("lock poisoned: {e}"))?
220-
.get(&session)
226+
.get(session)
221227
.cloned()
222228
.ok_or_else(|| format!("unknown session: {session}"))?;
223-
let line = serde_json::to_string(&op).map_err(|error| error.to_string())?;
224229
let mut stdin = target.stdin.lock().map_err(|error| error.to_string())?;
225230
stdin
226231
.write_all(line.as_bytes())
@@ -229,6 +234,76 @@ fn send_op(
229234
.map_err(|error| error.to_string())
230235
}
231236

237+
#[tauri::command]
238+
fn send_op(
239+
session: String,
240+
op: serde_json::Value,
241+
engines: tauri::State<Engines>,
242+
) -> Result<(), String> {
243+
let line = serde_json::to_string(&op).map_err(|error| error.to_string())?;
244+
write_line(&engines, &session, &line)
245+
}
246+
247+
/// Raw stdin write for non-jucode backends: the frontend adapter composes its
248+
/// own protocol frame (JSON-RPC for codex, stream-json for claude) and sends it
249+
/// as one line. Embedded newlines are rejected — one call, one frame.
250+
#[tauri::command]
251+
fn send_line(
252+
session: String,
253+
line: String,
254+
engines: tauri::State<Engines>,
255+
) -> Result<(), String> {
256+
if line.contains('\n') || line.contains('\r') {
257+
return Err("line must be a single frame (no embedded newlines)".to_string());
258+
}
259+
write_line(&engines, &session, &line)
260+
}
261+
262+
/// Availability report for one backend binary (settings / new-session UI).
263+
#[derive(Serialize)]
264+
struct BackendStatus {
265+
found: bool,
266+
path: Option<String>,
267+
version: Option<String>,
268+
}
269+
270+
/// Probes a backend binary: resolves it (honoring `bin_override`) and runs
271+
/// `<bin> --version` with a short timeout. `found` reflects the binary's
272+
/// presence; `version` is best-effort.
273+
#[tauri::command(async)]
274+
fn check_backend(backend: String, bin_override: Option<String>) -> Result<BackendStatus, String> {
275+
let kind = BackendKind::parse(&backend)?;
276+
let bin = backend::resolve_backend_bin(kind, bin_override.as_deref());
277+
// A bare name means "nothing found, hope PATH has it at spawn time" —
278+
// resolve it through PATH for the report (None when truly absent).
279+
let path = if bin.components().count() == 1 {
280+
which(&bin.to_string_lossy())
281+
} else if bin.is_file() {
282+
Some(bin)
283+
} else {
284+
None
285+
};
286+
let Some(path) = path else {
287+
return Ok(BackendStatus {
288+
found: false,
289+
path: None,
290+
version: None,
291+
});
292+
};
293+
let mut cmd = Command::new(&path);
294+
cmd.arg("--version");
295+
let version = run_with_timeout(cmd, std::time::Duration::from_secs(15))
296+
.ok()
297+
.filter(|out| out.status.success())
298+
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
299+
.filter(|v| !v.is_empty());
300+
Ok(BackendStatus {
301+
found: true,
302+
path: Some(path.display().to_string()),
303+
version,
304+
})
305+
}
306+
232307
fn jucode_dir() -> PathBuf {
233308
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
234309
PathBuf::from(home.unwrap_or_default()).join(".jucode")
@@ -609,8 +684,18 @@ fn project_root() -> String {
609684
}
610685

611686
/// Scans PATH for an executable named `cmd`, returning its full path.
687+
/// 终端环境快照可用时优先用快照 PATH(GUI 进程的 PATH 往往缺用户目录),
688+
/// 再回退进程自身 PATH。
612689
pub(crate) fn which(cmd: &str) -> Option<PathBuf> {
613-
let path = std::env::var_os("PATH")?;
690+
if let Some(snap) = shell_env::snapshot_path() {
691+
if let Some(found) = which_in(cmd, std::ffi::OsString::from(snap)) {
692+
return Some(found);
693+
}
694+
}
695+
which_in(cmd, std::env::var_os("PATH")?)
696+
}
697+
698+
fn which_in(cmd: &str, path: std::ffi::OsString) -> Option<PathBuf> {
614699
for dir in std::env::split_paths(&path) {
615700
let candidate = dir.join(cmd);
616701
if candidate.is_file() {
@@ -1291,10 +1376,8 @@ fn validate_git_args(args: &[String]) -> Result<(), String> {
12911376
}
12921377
}
12931378
// remote 只用于列出(remote -v),不放行 add/set-url 等子操作。
1294-
"remote" => {
1295-
if !positionals.is_empty() {
1296-
return Err("git remote only supports listing (-v)".to_string());
1297-
}
1379+
"remote" if !positionals.is_empty() => {
1380+
return Err("git remote only supports listing (-v)".to_string());
12981381
}
12991382
_ => {}
13001383
}
@@ -1532,6 +1615,9 @@ fn git(args: Vec<String>, cwd: Option<String>) -> Result<String, String> {
15321615
.first()
15331616
.is_some_and(|s| GIT_REMOTE_SUBCOMMANDS.contains(&s.as_str()));
15341617
let mut cmd = Command::new("git");
1618+
// 远程操作需要终端环境(SSH agent、凭据助手的 PATH 等)——合并快照但
1619+
// 不清空,协议性变量随后显式覆盖。
1620+
shell_env::merge_into(&mut cmd);
15351621
cmd.args(&args)
15361622
.current_dir(dir)
15371623
// 永不弹终端凭据提示:缺凭据直接失败,stderr 会带回前端展示。
@@ -1648,6 +1734,8 @@ fn gh(args: Vec<String>, cwd: Option<String>) -> Result<String, String> {
16481734
validate_gh_args(&args)?;
16491735
let dir = cwd.map(PathBuf::from).unwrap_or_else(resolve_cwd);
16501736
let mut cmd = Command::new(resolve_gh());
1737+
// gh 的登录态/配置常依赖终端环境(GH_CONFIG_DIR、代理等)。
1738+
shell_env::merge_into(&mut cmd);
16511739
cmd.args(&args)
16521740
.current_dir(dir)
16531741
// 全程非交互:未登录 / 缺配置时立即报错返回,绝不挂起等输入。
@@ -1916,6 +2004,8 @@ pub fn run() {
19162004
.plugin(tauri_plugin_notification::init())
19172005
.plugin(tauri_plugin_window_state::Builder::default().build())
19182006
.setup(|app| {
2007+
// 异步捕获登录 shell 环境快照(不阻塞启动;见 shell_env.rs)。
2008+
shell_env::init_async();
19192009
#[cfg(desktop)]
19202010
{
19212011
use tauri_plugin_deep_link::DeepLinkExt;
@@ -1946,6 +2036,10 @@ pub fn run() {
19462036
.invoke_handler(tauri::generate_handler![
19472037
create_session,
19482038
send_op,
2039+
send_line,
2040+
check_backend,
2041+
shell_env::shell_env_status,
2042+
shell_env::refresh_shell_env,
19492043
close_session,
19502044
read_config,
19512045
write_config,
@@ -1972,6 +2066,8 @@ pub fn run() {
19722066
git,
19732067
gh,
19742068
worktree_base,
2069+
claude_history::claude_sessions,
2070+
claude_history::claude_session_transcript,
19752071
pty_open,
19762072
pty_write,
19772073
pty_resize,

0 commit comments

Comments
 (0)