From 5caa022f97432dc33c233f67eeb11e555218c579 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 17:10:21 +0000 Subject: [PATCH] Add chat feature Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UKcFRLvsDn5u4u3vxuY962 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/chat.rs | 1468 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 10 + src/renderer/chat.js | 541 +++++++++++++ src/renderer/icons.js | 3 + src/renderer/index.html | 76 +- src/renderer/input.css | 40 + src/renderer/renderer.js | 2 + src/renderer/styles.css | 198 +++++ src/renderer/tauri-bridge.js | 10 + src/shared/i18n-dict.js | 190 +++++ 12 files changed, 2539 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/chat.rs create mode 100644 src/renderer/chat.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ef567f4..e29e346 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5374,6 +5374,7 @@ dependencies = [ "libc", "mio 1.2.1", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74010e7..ae58c44 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ tauri-plugin-log = "2" # Gateway core: localhost HTTP server + upstream client + streaming. axum = "0.8" reqwest = { version = "0.12", features = ["stream", "gzip", "brotli", "deflate", "json"] } -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time", "process"] } futures-util = "0.3" bytes = "1" async-stream = "0.3" diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs new file mode 100644 index 0000000..c30de47 --- /dev/null +++ b/src-tauri/src/chat.rs @@ -0,0 +1,1468 @@ +// New Session chat — run a coding CLI (Claude Code / Codex) as a child process per turn and +// stream its JSON event output into the renderer as normalized chat items (the cdesktop model, +// pared down to one-shot `-p` / `exec` turns resumed via the CLI's own session id). +// +// Wiring: when the local gateway is running, the spawned CLI is pointed at it per-process — +// Claude via ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN env, Codex via `-c` config overrides — +// so chats route through the active provider without touching the user's own CLI config. +// The CLIs still write their native transcripts (~/.claude/projects, ~/.codex/sessions), so +// finished chats also show up in the regular Sessions view. +// +// State: a registry (~/.ccbud/chat/sessions.json) + one JSONL of normalized items per session +// (~/.ccbud/chat/.jsonl). Live turns additionally emit `chat:event` {id, item}; text deltas +// are emit-only (never persisted) — the finalized message follows as its own item. + +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use tauri::Emitter; + +const EVENT: &str = "chat:event"; + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +// ---------- state ---------- + +#[derive(Default)] +pub struct ChatState { + // session id → sender that aborts the running turn's child process. + running: Mutex>>, + // session id → next item sequence number. Persisted items carry `seq` so the renderer can + // drop live events it already replayed from disk (chat_get vs chat:event race on open). + seqs: Mutex>, +} + +impl ChatState { + fn is_running(&self, id: &str) -> bool { + self.running.lock().map(|m| m.contains_key(id)).unwrap_or(false) + } + fn take_kill(&self, id: &str) -> Option> { + self.running.lock().ok().and_then(|mut m| m.remove(id)) + } + fn set_running(&self, id: &str, tx: tokio::sync::oneshot::Sender<()>) { + if let Ok(mut m) = self.running.lock() { + m.insert(id.to_string(), tx); + } + } + fn clear_running(&self, id: &str) { + if let Ok(mut m) = self.running.lock() { + m.remove(id); + } + } + pub fn kill_all(&self) { + if let Ok(mut m) = self.running.lock() { + for (_, tx) in m.drain() { + let _ = tx.send(()); + } + } + } + /// Next persisted-item sequence for a session, seeded from the on-disk item count. + fn next_seq(&self, id: &str) -> u64 { + let mut m = match self.seqs.lock() { + Ok(m) => m, + Err(_) => return 0, + }; + let n = m.entry(id.to_string()).or_insert_with(|| read_items(id).len() as u64); + let v = *n; + *n += 1; + v + } + fn drop_seq(&self, id: &str) { + if let Ok(mut m) = self.seqs.lock() { + m.remove(id); + } + } +} + +// ---------- persistence ---------- + +fn chat_dir() -> PathBuf { + crate::store::ccbud_home().join("chat") +} + +fn items_path(dir: &Path, id: &str) -> PathBuf { + // ids are generated by us (digits + dashes), safe as file names. + dir.join(format!("{}.jsonl", id)) +} + +fn read_registry_in(dir: &Path) -> Vec { + std::fs::read_to_string(dir.join("sessions.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() +} + +fn write_registry_in(dir: &Path, list: &[Value]) { + let _ = std::fs::create_dir_all(dir); + let path = dir.join("sessions.json"); + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, serde_json::to_vec_pretty(&Value::Array(list.to_vec())).unwrap_or_default()).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + +fn read_registry() -> Vec { + read_registry_in(&chat_dir()) +} + +fn write_registry(list: &[Value]) { + write_registry_in(&chat_dir(), list) +} + +/// Merge `patch` into the registry entry `id` (creating it if new) and bump updatedMs. +fn update_session_in(dir: &Path, id: &str, patch: Value) { + let mut list = read_registry_in(dir); + let mut found = false; + for s in list.iter_mut() { + if s.get("id").and_then(|v| v.as_str()) == Some(id) { + if let (Some(obj), Some(p)) = (s.as_object_mut(), patch.as_object()) { + for (k, v) in p { + obj.insert(k.clone(), v.clone()); + } + obj.insert("updatedMs".into(), json!(now_ms())); + } + found = true; + break; + } + } + if !found { + let mut obj = Map::new(); + obj.insert("id".into(), json!(id)); + obj.insert("createdMs".into(), json!(now_ms())); + obj.insert("updatedMs".into(), json!(now_ms())); + if let Some(p) = patch.as_object() { + for (k, v) in p { + obj.insert(k.clone(), v.clone()); + } + } + list.push(Value::Object(obj)); + } + write_registry_in(dir, &list); +} + +fn update_session(id: &str, patch: Value) { + update_session_in(&chat_dir(), id, patch) +} + +fn get_session_in(dir: &Path, id: &str) -> Option { + read_registry_in(dir).into_iter().find(|s| s.get("id").and_then(|v| v.as_str()) == Some(id)) +} + +fn get_session(id: &str) -> Option { + get_session_in(&chat_dir(), id) +} + +fn append_item_in(dir: &Path, id: &str, item: &Value) { + let _ = std::fs::create_dir_all(dir); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(items_path(dir, id)) { + let mut line = serde_json::to_vec(item).unwrap_or_default(); + line.push(b'\n'); + let _ = f.write_all(&line); + } +} + +fn append_item(id: &str, item: &Value) { + append_item_in(&chat_dir(), id, item) +} + +fn read_items_in(dir: &Path, id: &str) -> Vec { + std::fs::read_to_string(items_path(dir, id)) + .map(|s| s.lines().filter_map(|l| serde_json::from_str::(l).ok()).collect()) + .unwrap_or_default() +} + +fn read_items(id: &str) -> Vec { + read_items_in(&chat_dir(), id) +} + +// ---------- CLI resolution ---------- + +fn candidate_dirs() -> Vec { + let home = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")); + vec![ + home.join(".local/bin"), + home.join(".claude/local"), + home.join(".bun/bin"), + home.join(".cargo/bin"), + home.join(".volta/bin"), + home.join(".deno/bin"), + home.join("n/bin"), + home.join(".npm-global/bin"), + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + ] +} + +#[cfg(windows)] +fn exe_names(name: &str) -> Vec { + vec![format!("{}.exe", name), format!("{}.cmd", name), name.to_string()] +} +#[cfg(not(windows))] +fn exe_names(name: &str) -> Vec { + vec![name.to_string()] +} + +fn cli_cache() -> &'static Mutex>> { + static CACHE: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Resolve a CLI by name: PATH first, then common install dirs, then (unix) a login-shell +/// `command -v` — GUI apps on macOS don't inherit the user's shell PATH. Result is memoized; +/// a miss is retried on every call so installing the CLI mid-run is picked up. +pub(crate) fn find_cli(name: &str) -> Option { + if let Ok(cache) = cli_cache().lock() { + if let Some(Some(p)) = cache.get(name) { + if p.is_file() { + return Some(p.clone()); + } + } + } + let found = find_cli_uncached(name); + if let Ok(mut cache) = cli_cache().lock() { + cache.insert(name.to_string(), found.clone()); + } + found +} + +fn find_cli_uncached(name: &str) -> Option { + let names = exe_names(name); + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + for n in &names { + let p = dir.join(n); + if p.is_file() { + return Some(p); + } + } + } + } + for dir in candidate_dirs() { + for n in &names { + let p = dir.join(n); + if p.is_file() { + return Some(p); + } + } + } + #[cfg(unix)] + { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + if let Ok(out) = std::process::Command::new(&shell) + .args(["-l", "-c", &format!("command -v {}", name)]) + .output() + { + if out.status.success() { + let p = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !p.is_empty() { + let pb = PathBuf::from(p); + if pb.is_file() { + return Some(pb); + } + } + } + } + } + None +} + +/// Build a Command for a resolved CLI path. Windows `.cmd` shims need `cmd /C`. +fn cli_command(path: &Path) -> tokio::process::Command { + #[cfg(windows)] + { + if path.extension().map(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")).unwrap_or(false) { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/C").arg(path); + return c; + } + } + tokio::process::Command::new(path) +} + +// ---------- args / env ---------- + +struct GatewayWire { + port: u16, + token: String, + codex_model: String, +} + +fn claude_args(permission: &str, resume: Option<&str>) -> Vec { + let mode = match permission { + "plan" => "plan", + "full" => "bypassPermissions", + _ => "acceptEdits", + }; + let mut args = vec![ + "-p".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--include-partial-messages".to_string(), + "--permission-mode".to_string(), + mode.to_string(), + ]; + if let Some(sid) = resume { + args.push("--resume".to_string()); + args.push(sid.to_string()); + } + args +} + +fn codex_args(cwd: &str, permission: &str, resume: Option<&str>, gw: Option<&GatewayWire>) -> Vec { + // Flag order matters: exec-level flags go BEFORE the `resume` subcommand (clap parent flags + // are only valid after a subcommand when declared global; before works either way). + let mut args = vec!["exec".to_string()]; + args.push("--json".to_string()); + args.push("--skip-git-repo-check".to_string()); + args.push("-C".to_string()); + args.push(cwd.to_string()); + match permission { + "plan" => { + args.push("--sandbox".to_string()); + args.push("read-only".to_string()); + } + "full" => args.push("--dangerously-bypass-approvals-and-sandbox".to_string()), + _ => { + args.push("--sandbox".to_string()); + args.push("workspace-write".to_string()); + } + } + if let Some(gw) = gw { + // Same provider block codexconnect writes to config.toml, as per-process overrides. + let kv = [ + ("model_provider".to_string(), "\"ccbud\"".to_string()), + ("model".to_string(), format!("\"{}\"", gw.codex_model)), + ("model_providers.ccbud.name".to_string(), "\"CC Buddy\"".to_string()), + ( + "model_providers.ccbud.base_url".to_string(), + format!("\"http://127.0.0.1:{}/v1\"", gw.port), + ), + ("model_providers.ccbud.wire_api".to_string(), "\"responses\"".to_string()), + ("model_providers.ccbud.requires_openai_auth".to_string(), "false".to_string()), + ( + "model_providers.ccbud.experimental_bearer_token".to_string(), + format!("\"{}\"", gw.token), + ), + ]; + for (k, v) in kv { + args.push("-c".to_string()); + args.push(format!("{}={}", k, v)); + } + } + if let Some(sid) = resume { + args.push("resume".to_string()); + args.push(sid.to_string()); + } + // Prompt is piped through stdin. + args.push("-".to_string()); + args +} + +// ---------- normalized items ---------- + +fn item(kind: &str, fields: Value) -> Value { + let mut obj = Map::new(); + obj.insert("kind".into(), json!(kind)); + obj.insert("ts".into(), json!(now_ms())); + if let Some(f) = fields.as_object() { + for (k, v) in f { + obj.insert(k.clone(), v.clone()); + } + } + Value::Object(obj) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let cut: String = s.chars().take(max).collect(); + format!("{}…", cut) +} + +/// One-line human summary of a Claude tool_use input, keyed by tool name. +pub(crate) fn tool_detail(name: &str, input: &Value) -> String { + let s = |k: &str| input.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let d = match name { + "Bash" => s("command"), + "Read" | "Write" | "Edit" | "NotebookEdit" => s("file_path"), + "Glob" | "Grep" => { + let p = s("pattern"); + let path = s("path"); + if path.is_empty() { p } else { format!("{} · {}", p, path) } + } + "WebFetch" => s("url"), + "WebSearch" => s("query"), + "Task" => { + let d = s("description"); + if d.is_empty() { s("prompt") } else { d } + } + "TodoWrite" => { + let n = input.get("todos").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + format!("{} todos", n) + } + _ => String::new(), + }; + let d = if d.is_empty() { + serde_json::to_string(input).unwrap_or_default() + } else { + d + }; + truncate(d.replace('\n', " ").trim(), 220) +} + +fn text_of(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(arr) => arr + .iter() + .filter_map(|b| { + if b.get("type").and_then(|t| t.as_str()) == Some("text") { + b.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// Per-turn parser state shared across stdout lines. +#[derive(Default)] +pub(crate) struct TurnParse { + pub cli_session_id: Option, + pub saw_result: bool, + pub error: Option, +} + +/// Normalize one line of `claude -p --output-format stream-json` output. +pub(crate) fn parse_claude_line(line: &str, st: &mut TurnParse) -> Vec { + let rec: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => return vec![], + }; + if let Some(sid) = rec.get("session_id").and_then(|v| v.as_str()) { + if !sid.is_empty() { + st.cli_session_id = Some(sid.to_string()); + } + } + let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let mut out = vec![]; + match t { + "system" => { + if rec.get("subtype").and_then(|v| v.as_str()) == Some("init") { + out.push(item( + "meta", + json!({ + "model": rec.get("model").cloned().unwrap_or(Value::Null), + "cliSessionId": rec.get("session_id").cloned().unwrap_or(Value::Null), + }), + )); + } + } + "stream_event" => { + // Partial text/thinking deltas for live typing; emit-only (never persisted). + let ev = rec.get("event").cloned().unwrap_or(Value::Null); + if ev.get("type").and_then(|v| v.as_str()) == Some("content_block_delta") { + if let Some(d) = ev.get("delta") { + match d.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "text_delta" => { + if let Some(txt) = d.get("text").and_then(|v| v.as_str()) { + out.push(item("delta", json!({ "text": txt }))); + } + } + "thinking_delta" => { + if let Some(txt) = d.get("thinking").and_then(|v| v.as_str()) { + out.push(item("delta", json!({ "text": txt, "think": true }))); + } + } + _ => {} + } + } + } + } + "assistant" => { + // Sub-agent (Task) traffic replays with parent_tool_use_id set — the parent tool row + // already represents it, so keep the main thread clean. + if rec.get("parent_tool_use_id").and_then(|v| v.as_str()).is_some() { + return out; + } + let blocks = rec + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + .cloned() + .unwrap_or_default(); + for b in blocks { + match b.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "text" => { + let txt = b.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if !txt.trim().is_empty() { + out.push(item("assistant", json!({ "text": txt }))); + } + } + "thinking" => { + let txt = b.get("thinking").and_then(|v| v.as_str()).unwrap_or(""); + if !txt.trim().is_empty() { + out.push(item("thinking", json!({ "text": txt }))); + } + } + "tool_use" => { + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let input = b.get("input").cloned().unwrap_or(json!({})); + out.push(item( + "tool", + json!({ + "toolId": b.get("id").cloned().unwrap_or(Value::Null), + "name": name, + "detail": tool_detail(name, &input), + }), + )); + } + _ => {} + } + } + } + "user" => { + if rec.get("parent_tool_use_id").and_then(|v| v.as_str()).is_some() { + return out; + } + let blocks = rec + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + .cloned() + .unwrap_or_default(); + for b in blocks { + if b.get("type").and_then(|v| v.as_str()) == Some("tool_result") { + let is_err = b.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false); + let preview = truncate(text_of(&b.get("content").cloned().unwrap_or(Value::Null)).trim(), 400); + out.push(item( + "tool_result", + json!({ + "toolId": b.get("tool_use_id").cloned().unwrap_or(Value::Null), + "ok": !is_err, + "detail": preview, + }), + )); + } + } + } + "result" => { + st.saw_result = true; + let is_err = rec.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false); + if is_err { + st.error = Some( + rec.get("result") + .and_then(|v| v.as_str()) + .unwrap_or("run failed") + .to_string(), + ); + } + out.push(item( + "result", + json!({ + "ok": !is_err, + "durationMs": rec.get("duration_ms").cloned().unwrap_or(Value::Null), + "costUsd": rec.get("total_cost_usd").cloned().unwrap_or(Value::Null), + "usage": rec.get("usage").cloned().unwrap_or(Value::Null), + "error": if is_err { rec.get("result").cloned().unwrap_or(Value::Null) } else { Value::Null }, + }), + )); + } + _ => {} + } + out +} + +/// Normalize one line of `codex exec --json` output. Handles both the current thread/item +/// event stream and the older `{"msg":{...}}` protocol shape. +pub(crate) fn parse_codex_line(line: &str, st: &mut TurnParse) -> Vec { + let rec: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => return vec![], + }; + let mut out = vec![]; + + // Older codex: {"id":"...","msg":{"type":"agent_message",...}} + if let Some(msg) = rec.get("msg") { + let mt = msg.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match mt { + "session_configured" => { + if let Some(sid) = msg.get("session_id").and_then(|v| v.as_str()) { + st.cli_session_id = Some(sid.to_string()); + out.push(item("meta", json!({ "cliSessionId": sid, "model": msg.get("model").cloned().unwrap_or(Value::Null) }))); + } + } + "agent_message" => { + if let Some(txt) = msg.get("message").and_then(|v| v.as_str()) { + out.push(item("assistant", json!({ "text": txt }))); + } + } + "agent_message_delta" => { + if let Some(txt) = msg.get("delta").and_then(|v| v.as_str()) { + out.push(item("delta", json!({ "text": txt }))); + } + } + "agent_reasoning" => { + if let Some(txt) = msg.get("text").and_then(|v| v.as_str()) { + out.push(item("thinking", json!({ "text": txt }))); + } + } + "exec_command_begin" => { + let cmd = msg + .get("command") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(" ")) + .unwrap_or_default(); + out.push(item( + "tool", + json!({ "toolId": msg.get("call_id").cloned().unwrap_or(Value::Null), "name": "Shell", "detail": truncate(&cmd, 220) }), + )); + } + "exec_command_end" => { + let code = msg.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0); + out.push(item( + "tool_result", + json!({ "toolId": msg.get("call_id").cloned().unwrap_or(Value::Null), "ok": code == 0, "detail": "" }), + )); + } + "task_complete" => { + st.saw_result = true; + out.push(item("result", json!({ "ok": true }))); + } + "error" => { + let m = msg.get("message").and_then(|v| v.as_str()).unwrap_or("error"); + st.error = Some(m.to_string()); + out.push(item("error", json!({ "message": m }))); + } + _ => {} + } + return out; + } + + let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match t { + "thread.started" => { + if let Some(sid) = rec.get("thread_id").and_then(|v| v.as_str()) { + st.cli_session_id = Some(sid.to_string()); + out.push(item("meta", json!({ "cliSessionId": sid }))); + } + } + "item.started" | "item.updated" | "item.completed" => { + let it = rec.get("item").cloned().unwrap_or(json!({})); + let details = it.get("details").cloned().unwrap_or(Value::Null); + let field = |k: &str| -> Value { + it.get(k) + .cloned() + .or_else(|| details.get(k).cloned()) + .unwrap_or(Value::Null) + }; + let kind = it + .get("item_type") + .or_else(|| it.get("type")) + .or_else(|| details.get("type")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let done = t == "item.completed"; + match kind { + "assistant_message" | "agent_message" => { + if done { + if let Some(txt) = field("text").as_str() { + if !txt.trim().is_empty() { + out.push(item("assistant", json!({ "text": txt }))); + } + } + } + } + "reasoning" => { + if done { + if let Some(txt) = field("text").as_str() { + if !txt.trim().is_empty() { + out.push(item("thinking", json!({ "text": txt }))); + } + } + } + } + "command_execution" => { + let id = it.get("id").cloned().unwrap_or(Value::Null); + if t == "item.started" { + let cmd = field("command").as_str().unwrap_or("").to_string(); + out.push(item("tool", json!({ "toolId": id, "name": "Shell", "detail": truncate(&cmd, 220) }))); + } else if done { + let code = field("exit_code").as_i64(); + let status = field("status").as_str().unwrap_or("").to_string(); + let ok = code.map(|c| c == 0).unwrap_or(status != "failed"); + let tail = field("aggregated_output").as_str().unwrap_or("").to_string(); + let tail = tail.lines().rev().take(6).collect::>().into_iter().rev().collect::>().join("\n"); + out.push(item("tool_result", json!({ "toolId": id, "ok": ok, "detail": truncate(tail.trim(), 400) }))); + } + } + "file_change" => { + if done { + let paths = field("changes") + .as_array() + .map(|a| { + a.iter() + .filter_map(|c| c.get("path").and_then(|p| p.as_str())) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + out.push(item("tool", json!({ "toolId": it.get("id").cloned().unwrap_or(Value::Null), "name": "Edit", "detail": truncate(&paths, 220), "done": true }))); + } + } + "mcp_tool_call" => { + if done { + let name = format!( + "{}.{}", + field("server").as_str().unwrap_or("mcp"), + field("tool").as_str().unwrap_or("call") + ); + out.push(item("tool", json!({ "toolId": it.get("id").cloned().unwrap_or(Value::Null), "name": name, "detail": "", "done": true }))); + } + } + "web_search" => { + if done { + let q = field("query").as_str().unwrap_or("").to_string(); + out.push(item("tool", json!({ "toolId": it.get("id").cloned().unwrap_or(Value::Null), "name": "WebSearch", "detail": truncate(&q, 220), "done": true }))); + } + } + "todo_list" => { + if done { + let n = field("items").as_array().map(|a| a.len()).unwrap_or(0); + out.push(item("tool", json!({ "toolId": it.get("id").cloned().unwrap_or(Value::Null), "name": "Plan", "detail": format!("{} steps", n), "done": true }))); + } + } + "error" => { + let m = field("message").as_str().unwrap_or("error").to_string(); + st.error = Some(m.clone()); + out.push(item("error", json!({ "message": m }))); + } + _ => {} + } + } + "turn.completed" => { + st.saw_result = true; + out.push(item( + "result", + json!({ "ok": true, "usage": rec.get("usage").cloned().unwrap_or(Value::Null) }), + )); + } + "turn.failed" => { + st.saw_result = true; + let m = rec + .get("error") + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("turn failed") + .to_string(); + st.error = Some(m.clone()); + out.push(item("result", json!({ "ok": false, "error": m }))); + } + "error" => { + let m = rec.get("message").and_then(|v| v.as_str()).unwrap_or("error").to_string(); + st.error = Some(m.clone()); + out.push(item("error", json!({ "message": m }))); + } + _ => {} + } + out +} + +// ---------- turn runner ---------- + +fn emit(app: &tauri::AppHandle, id: &str, item: &Value) { + let _ = app.emit(EVENT, json!({ "id": id, "item": item })); +} + +/// Persist + emit one normalized item. `delta` and `status` are emit-only (live state; stale +/// after a restart) — everything else is stamped with a per-session `seq` and appended to disk. +fn push_item(app: &tauri::AppHandle, state: &ChatState, id: &str, it: &Value) { + let kind = it.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if kind == "delta" || kind == "status" { + emit(app, id, it); + return; + } + let mut stamped = it.clone(); + if let Some(obj) = stamped.as_object_mut() { + obj.insert("seq".into(), json!(state.next_seq(id))); + } + append_item(id, &stamped); + emit(app, id, &stamped); +} + +async fn gateway_wire(gw: &std::sync::Arc) -> Option { + let port = gw.current_port().await?; + let cfg = crate::store::read_config(); + Some(GatewayWire { + port, + token: crate::claude::current_token(&cfg), + codex_model: crate::codex_model(&cfg), + }) +} + +struct TurnSpec { + id: String, + agent: String, + cwd: String, + prompt: String, + permission: String, + resume: Option, +} + +async fn run_turn( + app: tauri::AppHandle, + state: std::sync::Arc, + gw: std::sync::Arc, + spec: TurnSpec, +) { + let id = spec.id.clone(); + let bin = match find_cli(&spec.agent) { + Some(b) => b, + None => { + let it = item("error", json!({ "message": format!("{} CLI not found", spec.agent), "code": "cliMissing" })); + push_item(&app, &state, &id, &it); + finish(&app, &state, &id, "error"); + return; + } + }; + + let wire = gateway_wire(&gw).await; + let via_gateway = wire.is_some(); + let mut cmd = cli_command(&bin); + if spec.agent == "claude" { + cmd.args(claude_args(&spec.permission, spec.resume.as_deref())); + cmd.current_dir(&spec.cwd); + if let Some(w) = &wire { + cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{}", w.port)); + cmd.env("ANTHROPIC_AUTH_TOKEN", &w.token); + } + } else { + cmd.args(codex_args(&spec.cwd, &spec.permission, spec.resume.as_deref(), wire.as_ref())); + cmd.current_dir(&spec.cwd); + } + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.set_running(&id, kill_tx); + push_item(&app, &state, &id, &item("status", json!({ "state": "running", "viaGateway": via_gateway }))); + + let is_claude = spec.agent == "claude"; + let outcome = stream_child(cmd, spec.prompt.clone(), is_claude, spec.resume.clone(), kill_rx, |it| { + if it.get("kind").and_then(|v| v.as_str()) == Some("meta") { + if let Some(sid) = it.get("cliSessionId").and_then(|v| v.as_str()) { + update_session(&id, json!({ "cliSessionId": sid })); + } + } + push_item(&app, &state, &id, &it); + }) + .await; + state.clear_running(&id); + + let o = match outcome { + Ok(o) => o, + Err(e) => { + push_item(&app, &state, &id, &item("error", json!({ "message": e }))); + finish(&app, &state, &id, "error"); + return; + } + }; + + if o.stopped { + push_item(&app, &state, &id, &item("result", json!({ "ok": false, "stopped": true }))); + } else if !o.parse.saw_result && !o.exit_ok { + // Crashed before emitting a result event — surface the stderr tail. + let msg = if o.stderr_tail.trim().is_empty() { + format!("{} exited with {:?}", spec.agent, o.exit_code) + } else { + truncate(o.stderr_tail.trim(), 600) + }; + push_item(&app, &state, &id, &item("error", json!({ "message": msg }))); + push_item(&app, &state, &id, &item("result", json!({ "ok": false, "error": msg }))); + } else if !o.parse.saw_result { + push_item(&app, &state, &id, &item("result", json!({ "ok": true }))); + } + + // Session id can also arrive only late in the stream (e.g. only in the result record). + if let Some(sid) = &o.parse.cli_session_id { + update_session(&id, json!({ "cliSessionId": sid })); + } + let failed = o.parse.error.is_some() || (!o.parse.saw_result && !o.exit_ok); + finish(&app, &state, &id, if !o.stopped && failed { "error" } else { "idle" }); +} + +struct StreamOutcome { + parse: TurnParse, + stopped: bool, + exit_ok: bool, + exit_code: Option, + stderr_tail: String, +} + +/// The whole child lifecycle: spawn the CLI, feed `prompt` on stdin, normalize each stdout line +/// into items for `on_item`, collect a rolling stderr tail, and stop early when `kill_rx` fires. +/// run_turn wraps this with session bookkeeping; tests drive it directly with mock CLIs. +async fn stream_child( + mut cmd: tokio::process::Command, + prompt: String, + is_claude: bool, + resume: Option, + mut kill_rx: tokio::sync::oneshot::Receiver<()>, + mut on_item: impl FnMut(Value), +) -> Result { + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + #[cfg(windows)] + { + // CREATE_NO_WINDOW — no flashing console on Windows. + use std::os::windows::process::CommandExt as _; + cmd.creation_flags(0x0800_0000); + } + + let mut child = cmd.spawn().map_err(|e| format!("spawn failed: {}", e))?; + + // Prompt goes through stdin (claude reads the -p prompt from stdin; codex got the "-" arg). + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(prompt.as_bytes()).await; + let _ = stdin.shutdown().await; + drop(stdin); + } + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + // stderr → rolling tail, surfaced only when the run dies without a result event. + let err_tail = std::sync::Arc::new(Mutex::new(String::new())); + let err_tail2 = err_tail.clone(); + let stderr_task = tokio::spawn(async move { + if let Some(se) = stderr { + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(se).lines(); + while let Ok(Some(l)) = lines.next_line().await { + if let Ok(mut t) = err_tail2.lock() { + t.push_str(&l); + t.push('\n'); + if t.len() > 4000 { + let cut = t.len() - 4000; + *t = t[cut..].to_string(); + } + } + } + } + }); + + let mut st = TurnParse::default(); + if resume.is_some() { + st.cli_session_id = resume; + } + let mut stopped = false; + + if let Some(so) = stdout { + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(so).lines(); + loop { + tokio::select! { + _ = &mut kill_rx => { + stopped = true; + let _ = child.start_kill(); + break; + } + line = lines.next_line() => { + match line { + Ok(Some(l)) => { + let items = if is_claude { parse_claude_line(&l, &mut st) } else { parse_codex_line(&l, &mut st) }; + for it in items { + on_item(it); + } + } + _ => break, + } + } + } + } + } + + let exit = child.wait().await; + let _ = stderr_task.await; + + Ok(StreamOutcome { + stopped, + exit_ok: exit.as_ref().map(|s| s.success()).unwrap_or(false), + exit_code: exit.ok().and_then(|s| s.code()), + stderr_tail: err_tail.lock().map(|t| t.clone()).unwrap_or_default(), + parse: st, + }) +} + +fn finish(app: &tauri::AppHandle, state: &ChatState, id: &str, state_str: &str) { + state.clear_running(id); + update_session(id, json!({ "status": state_str })); + emit(app, id, &item("status", json!({ "state": state_str }))); +} + +// ---------- commands ---------- + +static SESSION_SEQ: AtomicU64 = AtomicU64::new(0); + +fn new_id() -> String { + format!("{}-{}", now_ms(), SESSION_SEQ.fetch_add(1, Ordering::Relaxed)) +} + +fn expand_tilde(p: &str) -> String { + if let Some(rest) = p.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{}/{}", home, rest); + } + } + if p == "~" { + if let Ok(home) = std::env::var("HOME") { + return home; + } + } + p.to_string() +} + +#[tauri::command] +pub async fn chat_start( + app: tauri::AppHandle, + state: tauri::State<'_, std::sync::Arc>, + gw: tauri::State<'_, std::sync::Arc>, + agent: String, + cwd: String, + prompt: String, + permission: Option, +) -> Result { + let agent = if agent == "codex" { "codex" } else { "claude" }.to_string(); + let cwd = expand_tilde(cwd.trim()); + if !Path::new(&cwd).is_dir() { + return Ok(json!({ "ok": false, "reason": "badDir" })); + } + let prompt_trim = prompt.trim().to_string(); + if prompt_trim.is_empty() { + return Ok(json!({ "ok": false, "reason": "empty" })); + } + let permission = permission.unwrap_or_else(|| "edits".to_string()); + let id = new_id(); + let title = truncate(prompt_trim.replace('\n', " ").trim(), 64); + update_session( + &id, + json!({ "agent": agent, "cwd": cwd, "title": title, "permission": permission, "status": "running" }), + ); + push_item(&app, &state, &id, &item("user", json!({ "text": prompt_trim }))); + + let spec = TurnSpec { id: id.clone(), agent, cwd, prompt: prompt_trim, permission, resume: None }; + let st = state.inner().clone(); + let gws = gw.inner().clone(); + tauri::async_runtime::spawn(run_turn(app, st, gws, spec)); + Ok(json!({ "ok": true, "id": id })) +} + +#[tauri::command] +pub async fn chat_send( + app: tauri::AppHandle, + state: tauri::State<'_, std::sync::Arc>, + gw: tauri::State<'_, std::sync::Arc>, + id: String, + prompt: String, +) -> Result { + let sess = match get_session(&id) { + Some(s) => s, + None => return Ok(json!({ "ok": false, "reason": "notFound" })), + }; + if state.is_running(&id) { + return Ok(json!({ "ok": false, "reason": "busy" })); + } + let prompt_trim = prompt.trim().to_string(); + if prompt_trim.is_empty() { + return Ok(json!({ "ok": false, "reason": "empty" })); + } + let agent = sess.get("agent").and_then(|v| v.as_str()).unwrap_or("claude").to_string(); + let cwd = sess.get("cwd").and_then(|v| v.as_str()).unwrap_or(".").to_string(); + let permission = sess.get("permission").and_then(|v| v.as_str()).unwrap_or("edits").to_string(); + let resume = sess.get("cliSessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); + + update_session(&id, json!({ "status": "running" })); + push_item(&app, &state, &id, &item("user", json!({ "text": prompt_trim }))); + + let spec = TurnSpec { id: id.clone(), agent, cwd, prompt: prompt_trim, permission, resume }; + let st = state.inner().clone(); + let gws = gw.inner().clone(); + tauri::async_runtime::spawn(run_turn(app, st, gws, spec)); + Ok(json!({ "ok": true, "id": id })) +} + +#[tauri::command] +pub fn chat_stop(state: tauri::State<'_, std::sync::Arc>, id: String) -> Value { + if let Some(tx) = state.take_kill(&id) { + let _ = tx.send(()); + json!({ "ok": true }) + } else { + json!({ "ok": false, "reason": "notRunning" }) + } +} + +#[tauri::command] +pub fn chat_list(state: tauri::State<'_, std::sync::Arc>) -> Value { + let mut list = read_registry(); + for s in list.iter_mut() { + let id = s.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let running = state.is_running(&id); + if let Some(obj) = s.as_object_mut() { + // A stale "running" from a previous app run means the process is gone. + if !running && obj.get("status").and_then(|v| v.as_str()) == Some("running") { + obj.insert("status".into(), json!("idle")); + } + obj.insert("running".into(), json!(running)); + } + } + list.sort_by(|a, b| { + let am = a.get("updatedMs").and_then(|v| v.as_i64()).unwrap_or(0); + let bm = b.get("updatedMs").and_then(|v| v.as_i64()).unwrap_or(0); + bm.cmp(&am) + }); + Value::Array(list) +} + +#[tauri::command] +pub fn chat_get(state: tauri::State<'_, std::sync::Arc>, id: String) -> Value { + match get_session(&id) { + Some(mut s) => { + if let Some(obj) = s.as_object_mut() { + obj.insert("running".into(), json!(state.is_running(&id))); + } + json!({ "ok": true, "session": s, "items": read_items(&id) }) + } + None => json!({ "ok": false, "reason": "notFound" }), + } +} + +#[tauri::command] +pub fn chat_remove(state: tauri::State<'_, std::sync::Arc>, id: String) -> Value { + if let Some(tx) = state.take_kill(&id) { + let _ = tx.send(()); + } + state.drop_seq(&id); + let list: Vec = read_registry() + .into_iter() + .filter(|s| s.get("id").and_then(|v| v.as_str()) != Some(id.as_str())) + .collect(); + write_registry(&list); + let _ = std::fs::remove_file(items_path(&chat_dir(), &id)); + json!({ "ok": true }) +} + +#[tauri::command] +pub async fn chat_pick_dir() -> Result { + let folder = rfd::AsyncFileDialog::new().set_title("选择工作目录").pick_folder().await; + match folder { + Some(f) => Ok(json!({ "ok": true, "path": f.path().to_string_lossy() })), + None => Ok(json!({ "ok": false })), + } +} + +#[tauri::command] +pub async fn chat_agents() -> Result { + let probe = |name: &'static str| async move { + match find_cli(name) { + Some(p) => json!({ "available": true, "path": p.to_string_lossy() }), + None => json!({ "available": false }), + } + }; + Ok(json!({ "claude": probe("claude").await, "codex": probe("codex").await })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_stream_normalizes() { + let mut st = TurnParse::default(); + let init = r#"{"type":"system","subtype":"init","session_id":"s-1","model":"glm-5.2","cwd":"/x"}"#; + let items = parse_claude_line(init, &mut st); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["kind"], "meta"); + assert_eq!(st.cli_session_id.as_deref(), Some("s-1")); + + let delta = r#"{"type":"stream_event","session_id":"s-1","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}"#; + let items = parse_claude_line(delta, &mut st); + assert_eq!(items[0]["kind"], "delta"); + assert_eq!(items[0]["text"], "Hel"); + + let asst = r#"{"type":"assistant","session_id":"s-1","message":{"role":"assistant","content":[{"type":"text","text":"Hello!"},{"type":"tool_use","id":"tu1","name":"Bash","input":{"command":"ls -la"}}]}}"#; + let items = parse_claude_line(asst, &mut st); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["kind"], "assistant"); + assert_eq!(items[0]["text"], "Hello!"); + assert_eq!(items[1]["kind"], "tool"); + assert_eq!(items[1]["name"], "Bash"); + assert_eq!(items[1]["detail"], "ls -la"); + + // Sub-agent traffic (parent_tool_use_id) stays out of the main thread. + let sub = r#"{"type":"assistant","parent_tool_use_id":"tu1","session_id":"s-1","message":{"content":[{"type":"text","text":"sub"}]}}"#; + assert!(parse_claude_line(sub, &mut st).is_empty()); + + let tr = r#"{"type":"user","session_id":"s-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu1","is_error":false,"content":[{"type":"text","text":"file1\nfile2"}]}]}}"#; + let items = parse_claude_line(tr, &mut st); + assert_eq!(items[0]["kind"], "tool_result"); + assert_eq!(items[0]["ok"], true); + + let res = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1200,"total_cost_usd":0.01,"session_id":"s-1","result":"done","usage":{"input_tokens":10}}"#; + let items = parse_claude_line(res, &mut st); + assert_eq!(items[0]["kind"], "result"); + assert_eq!(items[0]["ok"], true); + assert!(st.saw_result); + assert!(st.error.is_none()); + + // garbage lines are ignored + assert!(parse_claude_line("not json", &mut st).is_empty()); + } + + #[test] + fn codex_thread_events_normalize() { + let mut st = TurnParse::default(); + let started = r#"{"type":"thread.started","thread_id":"t-9"}"#; + let items = parse_codex_line(started, &mut st); + assert_eq!(items[0]["kind"], "meta"); + assert_eq!(st.cli_session_id.as_deref(), Some("t-9")); + + let cmd_start = r#"{"type":"item.started","item":{"id":"item_0","item_type":"command_execution","command":"ls","status":"in_progress"}}"#; + let items = parse_codex_line(cmd_start, &mut st); + assert_eq!(items[0]["kind"], "tool"); + assert_eq!(items[0]["name"], "Shell"); + assert_eq!(items[0]["detail"], "ls"); + + let cmd_done = r#"{"type":"item.completed","item":{"id":"item_0","item_type":"command_execution","command":"ls","aggregated_output":"a\nb","exit_code":0,"status":"completed"}}"#; + let items = parse_codex_line(cmd_done, &mut st); + assert_eq!(items[0]["kind"], "tool_result"); + assert_eq!(items[0]["ok"], true); + + let reason = r#"{"type":"item.completed","item":{"id":"item_1","item_type":"reasoning","text":"thinking…"}}"#; + assert_eq!(parse_codex_line(reason, &mut st)[0]["kind"], "thinking"); + + let msg = r#"{"type":"item.completed","item":{"id":"item_2","item_type":"assistant_message","text":"All done"}}"#; + let items = parse_codex_line(msg, &mut st); + assert_eq!(items[0]["kind"], "assistant"); + assert_eq!(items[0]["text"], "All done"); + + let done = r#"{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":2}}"#; + let items = parse_codex_line(done, &mut st); + assert_eq!(items[0]["kind"], "result"); + assert_eq!(items[0]["ok"], true); + assert!(st.saw_result); + + let mut st2 = TurnParse::default(); + let failed = r#"{"type":"turn.failed","error":{"message":"boom"}}"#; + let items = parse_codex_line(failed, &mut st2); + assert_eq!(items[0]["ok"], false); + assert_eq!(st2.error.as_deref(), Some("boom")); + } + + #[test] + fn codex_legacy_msg_shape_normalizes() { + let mut st = TurnParse::default(); + let cfg = r#"{"id":"0","msg":{"type":"session_configured","session_id":"legacy-1","model":"gpt-5.4"}}"#; + let items = parse_codex_line(cfg, &mut st); + assert_eq!(items[0]["kind"], "meta"); + assert_eq!(st.cli_session_id.as_deref(), Some("legacy-1")); + + let m = r#"{"id":"1","msg":{"type":"agent_message","message":"hi"}}"#; + assert_eq!(parse_codex_line(m, &mut st)[0]["kind"], "assistant"); + + let e = r#"{"id":"2","msg":{"type":"exec_command_begin","call_id":"c1","command":["bash","-lc","pwd"]}}"#; + let items = parse_codex_line(e, &mut st); + assert_eq!(items[0]["kind"], "tool"); + assert_eq!(items[0]["detail"], "bash -lc pwd"); + + let tc = r#"{"id":"3","msg":{"type":"task_complete"}}"#; + assert_eq!(parse_codex_line(tc, &mut st)[0]["kind"], "result"); + assert!(st.saw_result); + } + + #[test] + fn tool_details_are_compact() { + assert_eq!(tool_detail("Bash", &json!({"command": "echo hi"})), "echo hi"); + assert_eq!(tool_detail("Read", &json!({"file_path": "/a/b.txt"})), "/a/b.txt"); + assert_eq!(tool_detail("Grep", &json!({"pattern": "foo", "path": "src"})), "foo · src"); + assert_eq!(tool_detail("TodoWrite", &json!({"todos": [1, 2]})), "2 todos"); + let long = "x".repeat(500); + assert!(tool_detail("Bash", &json!({ "command": long })).chars().count() <= 221); + // unknown tools fall back to compact JSON + assert!(tool_detail("Foo", &json!({"a": 1})).contains("\"a\"")); + } + + #[test] + fn args_build_correctly() { + let a = claude_args("edits", None); + assert!(a.contains(&"--permission-mode".to_string()) && a.contains(&"acceptEdits".to_string())); + assert!(a.contains(&"--output-format".to_string()) && a.contains(&"stream-json".to_string())); + assert!(!a.contains(&"--resume".to_string())); + let a = claude_args("full", Some("sid-1")); + assert!(a.contains(&"bypassPermissions".to_string())); + let i = a.iter().position(|x| x == "--resume").unwrap(); + assert_eq!(a[i + 1], "sid-1"); + + let gw = GatewayWire { port: 8788, token: "tok".into(), codex_model: "gpt-5.4".into() }; + let c = codex_args("/w", "edits", None, Some(&gw)); + assert_eq!(c[0], "exec"); + assert!(c.contains(&"--json".to_string())); + assert!(c.contains(&"model_providers.ccbud.base_url=\"http://127.0.0.1:8788/v1\"".to_string())); + assert!(c.contains(&"--sandbox".to_string()) && c.contains(&"workspace-write".to_string())); + assert_eq!(c.last().unwrap(), "-"); + let c = codex_args("/w", "full", Some("t1"), None); + // resume subcommand comes after all exec-level flags, right before the stdin sentinel + assert_eq!(&c[c.len() - 3..], &["resume".to_string(), "t1".to_string(), "-".to_string()]); + assert!(c.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string())); + assert!(!c.iter().any(|x| x.starts_with("model_provider"))); + } + + // Real child-process runs against mock CLIs — covers spawn, stdin prompt delivery, stdout + // normalization, stderr tail collection, and the stop path. + #[cfg(unix)] + mod spawn { + use super::*; + + fn write_script(dir: &Path, name: &str, body: &str) -> PathBuf { + let _ = std::fs::create_dir_all(dir); + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p + } + + // Run scripts via `sh `, not by exec'ing the file we just wrote — concurrent tests + // fork while another test's script fd is still open, and exec'ing that file races into + // ETXTBSY. The interpreter only reads it. + fn script_command(p: &Path) -> tokio::process::Command { + let mut c = tokio::process::Command::new("sh"); + c.arg(p); + c + } + + fn tdir(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("ccbud-chat-spawn-{}-{}", tag, std::process::id())) + } + + #[tokio::test] + async fn streams_a_mock_claude_run() { + let dir = tdir("ok"); + let script = write_script( + &dir, + "mock-claude", + "prompt=$(cat)\necho '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"mock-1\",\"model\":\"m1\"}'\nprintf '{\"type\":\"assistant\",\"session_id\":\"mock-1\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"echo: %s\"}]}}\\n' \"$prompt\"\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"duration_ms\":5,\"session_id\":\"mock-1\",\"result\":\"ok\"}'\n", + ); + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + let mut got: Vec = vec![]; + let out = stream_child(script_command(&script), "hi-42".into(), true, None, rx, |it| got.push(it)) + .await + .unwrap(); + assert!(out.exit_ok && !out.stopped); + assert!(out.parse.saw_result); + assert_eq!(out.parse.cli_session_id.as_deref(), Some("mock-1")); + let kinds: Vec<&str> = got.iter().map(|i| i["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, vec!["meta", "assistant", "result"]); + // the prompt made the round trip through stdin + assert_eq!(got[1]["text"], "echo: hi-42"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn stop_kills_a_hung_cli() { + let dir = tdir("kill"); + let script = write_script( + &dir, + "mock-hang", + "cat >/dev/null\necho '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"mock-2\"}'\nexec sleep 30\n", + ); + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let mut kill = Some(tx); + let fut = stream_child(script_command(&script), "x".into(), true, None, rx, move |_it| { + // First item → stop, like the renderer's Stop button. + if let Some(t) = kill.take() { + let _ = t.send(()); + } + }); + let out = tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .expect("kill must end the run quickly") + .unwrap(); + assert!(out.stopped); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn crash_surfaces_stderr_tail() { + let dir = tdir("crash"); + let script = write_script( + &dir, + "mock-crash", + "cat >/dev/null\necho 'boom: bad flag' >&2\nexit 3\n", + ); + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + let out = stream_child(script_command(&script), "x".into(), true, None, rx, |_it| {}) + .await + .unwrap(); + assert!(!out.exit_ok && !out.stopped && !out.parse.saw_result); + assert_eq!(out.exit_code, Some(3)); + assert!(out.stderr_tail.contains("boom: bad flag")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn streams_a_mock_codex_run() { + let dir = tdir("codex"); + let script = write_script( + &dir, + "mock-codex", + "cat >/dev/null\necho '{\"type\":\"thread.started\",\"thread_id\":\"th-1\"}'\necho '{\"type\":\"item.completed\",\"item\":{\"id\":\"i1\",\"item_type\":\"assistant_message\",\"text\":\"done\"}}'\necho '{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}'\n", + ); + let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); + let mut got: Vec = vec![]; + let out = stream_child(script_command(&script), "x".into(), false, None, rx, |it| got.push(it)) + .await + .unwrap(); + assert!(out.exit_ok && out.parse.saw_result); + assert_eq!(out.parse.cli_session_id.as_deref(), Some("th-1")); + let kinds: Vec<&str> = got.iter().map(|i| i["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, vec!["meta", "assistant", "result"]); + let _ = std::fs::remove_dir_all(&dir); + } + } + + #[test] + fn registry_and_items_round_trip() { + // Explicit dir — never touches CCBUD_HOME (process-global env would race other tests). + let dir = std::env::temp_dir().join(format!("ccbud-chat-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + update_session_in(&dir, "a1", json!({ "agent": "claude", "cwd": "/x", "title": "hello" })); + update_session_in(&dir, "a1", json!({ "cliSessionId": "s-9" })); + let s = get_session_in(&dir, "a1").unwrap(); + assert_eq!(s["agent"], "claude"); + assert_eq!(s["cliSessionId"], "s-9"); + assert!(s["createdMs"].as_i64().unwrap() > 0); + + append_item_in(&dir, "a1", &item("user", json!({ "text": "hi" }))); + append_item_in(&dir, "a1", &item("assistant", json!({ "text": "yo" }))); + let items = read_items_in(&dir, "a1"); + assert_eq!(items.len(), 2); + assert_eq!(items[1]["kind"], "assistant"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa6128c..5cd5bdc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ #![allow(unused_variables)] mod antigravity; +mod chat; mod claude; mod codex; mod codexconnect; @@ -1840,6 +1841,8 @@ pub fn run() { pm.sync_providers(); // reconcile services with installed plugins on boot let pm_boot = pm.clone(); app.manage(pm); + // New Session chat runner (chat.rs) — tracks live CLI child processes. + app.manage(std::sync::Arc::new(chat::ChatState::default())); let startup_cfg = store::read_config(); // Repair previously managed targets that remain selected. A compatibility backup is // required per target because old defaults could persist `["claude"]` without any @@ -2239,6 +2242,7 @@ pub fn run() { app_open_main, app_quit, window_settings_mode, window_view_min_width, history_projects, history_list, history_get, history_search, history_dirs, history_pick_dir, history_set_active, history_import, history_import_paths, history_remove_import, history_set_meta, history_delete_forever, history_export_raw, history_export_html, + chat::chat_start, chat::chat_send, chat::chat_stop, chat::chat_list, chat::chat_get, chat::chat_remove, chat::chat_pick_dir, chat::chat_agents, util_copy, util_open_external, update_state, update_check, update_download, update_apply, update_set_auto, selfcheck_report, selfcheck_routing, selfcheck_gateway, selfcheck_history, selfcheck_export, selfcheck_import, selfcheck_popover @@ -2246,6 +2250,12 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { + // Quitting: stop any CLI child processes the chat runner still has live. + if matches!(event, tauri::RunEvent::Exit) { + if let Some(cs) = app_handle.try_state::>() { + cs.kill_all(); + } + } // Keep running in the tray when the user closes the window (hide instead of quit). if let tauri::RunEvent::WindowEvent { label, diff --git a/src/renderer/chat.js b/src/renderer/chat.js new file mode 100644 index 0000000..614b1ae --- /dev/null +++ b/src/renderer/chat.js @@ -0,0 +1,541 @@ +'use strict'; + +/* + * New Session chat — drive Claude Code / Codex CLI runs from inside the app. + * Backend contract (src-tauri/src/chat.rs): chatStart/chatSend spawn one CLI turn and stream + * normalized items over the `chat:event` channel ({id, item}); finalized items are also + * persisted so chatGet can replay a session after app restarts. Delta items are live-only. + */ +(function () { + const api = window.ccbud; + const $ = (id) => document.getElementById(id); + const L = (k, p) => (window.I18n ? window.I18n.t(k, p) : k); + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const md = (text) => { try { return window.marked ? window.marked.parse(String(text || '')) : esc(text); } catch (_) { return esc(text); } }; + + const AGENT_META = { + claude: { name: 'Claude Code', icon: 'assets/claude.svg' }, + codex: { name: 'Codex', icon: 'assets/chatgpt.svg' }, + }; + + let sessions = []; + let activeId = null; + let agents = { claude: { available: false }, codex: { available: false } }; + let setupAgent = 'claude'; + let setupPerm = 'edits'; + let liveText = ''; // accumulated assistant text deltas for the in-flight turn + let liveThink = ''; // accumulated thinking deltas + let maxSeq = -1; // highest persisted-item seq already in the transcript (dedupe on open) + let shown = false; + + function activeSession() { return sessions.find((s) => s.id === activeId) || null; } + function isRunning(s) { return !!(s && (s.running || s.status === 'running')); } + + function relTime(ts) { + if (!ts) return ''; + const d = Date.now() - ts; + if (d < 60000) return L('chat.justNow'); + if (d < 3600000) return `${Math.floor(d / 60000)}m`; + if (d < 86400000) return `${Math.floor(d / 3600000)}h`; + return `${Math.floor(d / 86400000)}d`; + } + + function baseName(p) { return String(p || '').split(/[\\/]/).filter(Boolean).pop() || p || ''; } + + /* ---------- session list ---------- */ + + function renderSessionList() { + const host = $('chatSessionList'); + if (!host) return; + if (!sessions.length) { + host.innerHTML = `
${esc(L('chat.empty'))}
`; + return; + } + host.innerHTML = sessions.map((s) => { + const meta = AGENT_META[s.agent] || AGENT_META.claude; + const running = isRunning(s); + const err = s.status === 'error'; + const dot = running + ? '' + : err ? '' : ''; + return `
+
+ + ${esc(s.title || L('chat.untitled'))} + ${dot} +
+
+ ${esc(baseName(s.cwd))} + ${esc(relTime(s.updatedMs))} +
+ +
`; + }).join(''); + } + + async function refreshSessions() { + try { sessions = (await api.chatList()) || []; } catch (_) { sessions = []; } + renderSessionList(); + } + + /* ---------- setup form ---------- */ + + function renderAgentAvailability() { + document.querySelectorAll('#chatAgentSeg .chat-agent-btn').forEach((b) => { + const a = agents[b.dataset.agent] || {}; + const st = b.querySelector('.chat-agent-state'); + if (st) { + st.textContent = a.available ? L('chat.installed') : L('chat.notInstalled'); + st.classList.toggle('text-green', !!a.available); + } + b.classList.toggle('opacity-55', !a.available); + }); + } + + async function refreshAgents() { + try { agents = (await api.chatAgents()) || agents; } catch (_) {} + renderAgentAvailability(); + } + + function showSetup() { + activeId = null; + $('chatSetup').classList.remove('hidden'); + const t = $('chatTranscript'); + t.classList.add('hidden'); t.classList.remove('flex'); + $('chatComposer').classList.add('hidden'); + $('chatHeader').classList.add('hidden'); $('chatHeader').classList.remove('flex'); + renderSessionList(); + setTimeout(() => { const p = $('chatSetupPrompt'); if (p) p.focus(); }, 60); + } + + /* ---------- transcript rendering ---------- */ + + function transcriptEl() { return $('chatTranscript'); } + + function nearBottom() { + const el = $('chatBody'); + return el.scrollHeight - el.scrollTop - el.clientHeight < 140; + } + function scrollBottom(force) { + const el = $('chatBody'); + if (force || nearBottom()) el.scrollTop = el.scrollHeight; + } + + function highlightIn(root) { + if (!window.hljs) return; + root.querySelectorAll('pre code').forEach((el) => { try { window.hljs.highlightElement(el); } catch (_) {} }); + } + + function agentLabel() { + const s = activeSession(); + return (AGENT_META[s && s.agent] || AGENT_META.claude).name; + } + + function userNode(text) { + const div = document.createElement('div'); + div.className = 'msg user flex flex-col gap-1.25 animate-[panelIn_0.18s_cubic-bezier(0.23,1,0.32,1)] w-full'; + div.innerHTML = `
👤 ${esc(L('conv.you'))}
+
`; + div.querySelector('.msg-body').textContent = text; + return div; + } + + function assistantNode(html) { + const div = document.createElement('div'); + div.className = 'msg assistant flex flex-col gap-1.25 animate-[panelIn_0.18s_cubic-bezier(0.23,1,0.32,1)] w-full'; + div.innerHTML = `
✦ ${esc(agentLabel())}
+
${html}
`; + return div; + } + + function thinkingNode(text) { + const div = document.createElement('div'); + div.className = 'w-full'; + div.innerHTML = `
+ ◦ ${esc(L('chat.thinking'))} +
`; + div.querySelector('details > div').textContent = text; + return div; + } + + function toolNode(it) { + const div = document.createElement('div'); + div.className = 'chat-tool w-full flex flex-col gap-0.5'; + if (it.toolId) div.dataset.toolid = it.toolId; + const done = !!it.done; + div.innerHTML = `
+ ${done ? '' : ''} + ${esc(it.name || 'tool')} + ${esc(it.detail || '')} +
+ `; + return div; + } + + function resultNode(it) { + const div = document.createElement('div'); + div.className = 'w-full flex items-center gap-2 py-0.5'; + let parts = []; + if (it.stopped) parts.push(`■ ${L('chat.stoppedMark')}`); + else if (it.ok) parts.push(`✓ ${L('chat.doneMark')}`); + else parts.push(`✕ ${L('chat.failedMark')}`); + if (it.durationMs) parts.push((it.durationMs / 1000).toFixed(1) + 's'); + if (it.costUsd) parts.push('$' + Number(it.costUsd).toFixed(4)); + const u = it.usage || {}; + const tok = (u.input_tokens || 0) + (u.output_tokens || 0); + if (tok) parts.push(tok.toLocaleString() + ' tok'); + const cls = it.ok ? 'text-caption' : 'text-red'; + div.innerHTML = ` + ${esc(parts.join(' · '))} + `; + return div; + } + + function errorNode(msg) { + const div = document.createElement('div'); + div.className = 'w-full'; + div.innerHTML = `
`; + div.firstElementChild.textContent = msg; + return div; + } + + /* live (streaming) bubble管理 */ + function liveNode() { + let el = transcriptEl().querySelector('.chat-live'); + if (!el) { + el = assistantNode(''); + el.classList.add('chat-live'); + el.querySelector('.msg-body').classList.add('chat-live-body', 'whitespace-pre-wrap', 'break-words'); + el.querySelector('.msg-body').classList.replace('border-border-strong', 'border-green'); + transcriptEl().appendChild(el); + } + return el; + } + function liveThinkNode() { + let el = transcriptEl().querySelector('.chat-live-think'); + if (!el) { + el = document.createElement('div'); + el.className = 'chat-live-think w-full text-[12px] text-muted border-l-2 border-border-custom pl-3'; + el.innerHTML = `
◦ ${esc(L('chat.thinking'))}…
+
`; + transcriptEl().appendChild(el); + } + return el; + } + function clearLive() { + liveText = ''; + liveThink = ''; + const el = transcriptEl().querySelector('.chat-live'); + if (el) el.remove(); + const t = transcriptEl().querySelector('.chat-live-think'); + if (t) t.remove(); + } + + /* apply one normalized item to the transcript DOM */ + function applyItem(it, replaying) { + const host = transcriptEl(); + const stick = replaying ? false : nearBottom(); + switch (it.kind) { + case 'user': + clearLive(); + host.appendChild(userNode(it.text || '')); + break; + case 'delta': { + if (replaying) return; // deltas are live-only + if (it.think) { + liveThink += it.text || ''; + const el = liveThinkNode(); + el.querySelector('.chat-live-think-text').textContent = liveThink.length > 1200 ? '…' + liveThink.slice(-1200) : liveThink; + } else { + liveText += it.text || ''; + const el = liveNode(); + el.querySelector('.msg-body').textContent = liveText; + } + break; + } + case 'thinking': { + const lt = host.querySelector('.chat-live-think'); + if (lt) lt.remove(); + liveThink = ''; + host.appendChild(thinkingNode(it.text || '')); + break; + } + case 'assistant': { + const lv = host.querySelector('.chat-live'); + if (lv) lv.remove(); + liveText = ''; + const node = assistantNode(md(it.text || '')); + host.appendChild(node); + highlightIn(node); + break; + } + case 'tool': { + // Codex re-announces tools as done; refresh in place when the row exists. + const prev = it.toolId ? host.querySelector(`.chat-tool[data-toolid="${CSS.escape(String(it.toolId))}"]`) : null; + const node = toolNode(it); + if (prev) prev.replaceWith(node); else host.appendChild(node); + break; + } + case 'tool_result': { + const row = it.toolId ? host.querySelector(`.chat-tool[data-toolid="${CSS.escape(String(it.toolId))}"]`) : null; + if (row) { + const st = row.querySelector('.chat-tool-state'); + if (st) st.innerHTML = it.ok ? '' : ''; + if (it.detail && !it.ok) { + const out = row.querySelector('.chat-tool-out'); + if (out) { + out.classList.remove('hidden'); + out.innerHTML = `
`;
+              out.firstElementChild.textContent = it.detail;
+            }
+          }
+        }
+        break;
+      }
+      case 'result':
+        clearLive();
+        host.appendChild(resultNode(it));
+        break;
+      case 'error':
+        clearLive();
+        host.appendChild(errorNode(it.message || 'error'));
+        break;
+      case 'meta':
+      case 'status':
+        break; // reflected in header/composer state, not the transcript
+      default:
+        break;
+    }
+    if (!replaying && stick) scrollBottom(true);
+  }
+
+  /* ---------- header / composer state ---------- */
+
+  function syncHeader() {
+    const s = activeSession();
+    const head = $('chatHeader');
+    if (!s) { head.classList.add('hidden'); head.classList.remove('flex'); return; }
+    head.classList.remove('hidden'); head.classList.add('flex');
+    const meta = AGENT_META[s.agent] || AGENT_META.claude;
+    $('chatHeaderIcon').src = meta.icon;
+    $('chatHeaderTitle').textContent = s.title || L('chat.untitled');
+    $('chatHeaderCwd').textContent = s.cwd || '';
+    const st = $('chatHeaderStatus');
+    if (isRunning(s)) { st.innerHTML = `${esc(L('chat.running'))}`; }
+    else if (s.status === 'error') { st.textContent = L('chat.errorState'); }
+    else { st.textContent = ''; }
+    syncComposer();
+  }
+
+  function syncComposer() {
+    const running = isRunning(activeSession());
+    const send = $('chatSendBtn');
+    const stop = $('chatStopBtn');
+    send.classList.toggle('hidden', running);
+    send.classList.toggle('flex', !running);
+    stop.classList.toggle('hidden', !running);
+    stop.classList.toggle('flex', running);
+  }
+
+  /* ---------- open / start / send ---------- */
+
+  async function openSession(id) {
+    activeId = id;
+    clearLive();
+    let res = null;
+    try { res = await api.chatGet(id); } catch (_) {}
+    if (!res || !res.ok) { showSetup(); return; }
+    // merge fresh session info (running flag) into our list copy
+    const i = sessions.findIndex((s) => s.id === id);
+    if (i >= 0) sessions[i] = Object.assign({}, sessions[i], res.session);
+    else sessions.unshift(res.session);
+
+    $('chatSetup').classList.add('hidden');
+    const t = transcriptEl();
+    t.innerHTML = '';
+    t.classList.remove('hidden'); t.classList.add('flex');
+    $('chatComposer').classList.remove('hidden');
+    maxSeq = -1;
+    (res.items || []).forEach((it) => { if (it.seq != null && it.seq > maxSeq) maxSeq = it.seq; applyItem(it, true); });
+    highlightIn(t);
+    renderSessionList();
+    syncHeader();
+    scrollBottom(true);
+    setTimeout(() => { const inp = $('chatInput'); if (inp) inp.focus(); }, 60);
+  }
+
+  async function startSession() {
+    const cwd = $('chatCwd').value.trim();
+    const prompt = $('chatSetupPrompt').value.trim();
+    const hint = $('chatSetupHint');
+    hint.textContent = '';
+    if (!prompt) { hint.textContent = L('chat.needTask'); return; }
+    if (!cwd) { hint.textContent = L('chat.needDir'); return; }
+    const btn = $('chatStartBtn');
+    btn.disabled = true;
+    try {
+      const res = await api.chatStart(setupAgent, cwd, prompt, setupPerm);
+      if (!res || !res.ok) {
+        hint.textContent = res && res.reason === 'badDir' ? L('chat.badDir') : L('chat.startFailed');
+        return;
+      }
+      try { localStorage.setItem('ccbud-chat-cwd', cwd); } catch (_) {}
+      $('chatSetupPrompt').value = '';
+      await refreshSessions();
+      await openSession(res.id);
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
+  async function sendFollowUp() {
+    const s = activeSession();
+    if (!s || isRunning(s)) return;
+    const inp = $('chatInput');
+    const text = inp.value.trim();
+    if (!text) return;
+    inp.value = '';
+    inp.style.height = 'auto';
+    const res = await api.chatSend(s.id, text).catch(() => null);
+    if (!res || !res.ok) {
+      if (res && res.reason === 'busy') showToast(L('chat.busyToast'), 'err');
+      else showToast(L('chat.startFailed'), 'err');
+      inp.value = text;
+      return;
+    }
+    s.status = 'running'; s.running = true;
+    syncHeader();
+    renderSessionList();
+  }
+
+  async function stopRun() {
+    const s = activeSession();
+    if (!s) return;
+    await api.chatStop(s.id).catch(() => {});
+  }
+
+  /* ---------- events from backend ---------- */
+
+  function onChatEvent(payload) {
+    if (!payload || !payload.id) return;
+    const { id, item } = payload;
+    const s = sessions.find((x) => x.id === id);
+    if (item.kind === 'status') {
+      if (s) {
+        s.status = item.state === 'running' ? 'running' : item.state;
+        s.running = item.state === 'running';
+        s.updatedMs = Date.now();
+      }
+      if (id === activeId) {
+        if (s && item.viaGateway != null) $('chatHeaderGateway').classList.toggle('hidden', !item.viaGateway);
+        syncHeader();
+      }
+      renderSessionList();
+      return;
+    }
+    if (item.kind === 'meta') {
+      if (s && item.cliSessionId) s.cliSessionId = item.cliSessionId;
+      return;
+    }
+    if (id !== activeId) {
+      if (s && (item.kind === 'user' || item.kind === 'assistant')) { s.updatedMs = Date.now(); renderSessionList(); }
+      return;
+    }
+    // Persisted items carry `seq`; drop any event the chatGet replay already covered.
+    if (item.seq != null) {
+      if (item.seq <= maxSeq) return;
+      maxSeq = item.seq;
+    }
+    applyItem(item, false);
+  }
+
+  /* ---------- wiring ---------- */
+
+  function bind() {
+    if (!api || !$('view-chat')) return;
+
+    $('chatNewBtn').addEventListener('click', showSetup);
+    $('chatStartBtn').addEventListener('click', startSession);
+    $('chatSendBtn').addEventListener('click', sendFollowUp);
+    $('chatStopBtn').addEventListener('click', stopRun);
+
+    $('chatAgentSeg').addEventListener('click', (e) => {
+      const b = e.target.closest('.chat-agent-btn');
+      if (!b) return;
+      setupAgent = b.dataset.agent;
+      document.querySelectorAll('#chatAgentSeg .chat-agent-btn').forEach((x) => x.classList.toggle('active', x === b));
+    });
+    $('chatPermSeg').addEventListener('click', (e) => {
+      const b = e.target.closest('.chat-perm-btn');
+      if (!b) return;
+      setupPerm = b.dataset.perm;
+      document.querySelectorAll('#chatPermSeg .chat-perm-btn').forEach((x) => x.classList.toggle('active', x === b));
+    });
+
+    $('chatPickDir').addEventListener('click', async () => {
+      const res = await api.chatPickDir().catch(() => null);
+      if (res && res.ok && res.path) $('chatCwd').value = res.path;
+    });
+
+    // Setup textarea: Cmd/Ctrl+Enter starts the session.
+    $('chatSetupPrompt').addEventListener('keydown', (e) => {
+      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); startSession(); }
+    });
+
+    // Composer: Enter sends, Shift+Enter is a newline; auto-grow up to the CSS max-height.
+    const inp = $('chatInput');
+    inp.addEventListener('keydown', (e) => {
+      if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendFollowUp(); }
+    });
+    inp.addEventListener('input', () => {
+      inp.style.height = 'auto';
+      inp.style.height = Math.min(inp.scrollHeight, 140) + 'px';
+    });
+
+    $('chatSessionList').addEventListener('click', async (e) => {
+      const del = e.target.closest('[data-del]');
+      if (del) {
+        e.stopPropagation();
+        const id = del.dataset.del;
+        const s = sessions.find((x) => x.id === id);
+        const ok = await confirmDialog({
+          title: L('chat.deleteTitle'),
+          message: L('chat.deleteMsg', { name: (s && s.title) || '' }),
+          confirmText: L('chat.delete'),
+          cancelText: L('modal.cancel'),
+          danger: true,
+        });
+        if (!ok) return;
+        await api.chatRemove(id).catch(() => {});
+        if (activeId === id) showSetup();
+        await refreshSessions();
+        return;
+      }
+      const row = e.target.closest('[data-sid]');
+      if (row) openSession(row.dataset.sid);
+    });
+
+    api.onChatEvent(onChatEvent);
+
+    try {
+      const last = localStorage.getItem('ccbud-chat-cwd');
+      if (last) $('chatCwd').value = last;
+    } catch (_) {}
+  }
+
+  async function onShow() {
+    if (!shown) { shown = true; }
+    await refreshSessions();
+    refreshAgents();
+    if (activeId && sessions.some((s) => s.id === activeId)) {
+      syncHeader();
+    } else if (!sessions.length || !activeId) {
+      showSetup();
+    }
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind);
+  else bind();
+
+  window.ccbudChat = { onShow };
+})();
diff --git a/src/renderer/icons.js b/src/renderer/icons.js
index f4d4f91..a09298e 100644
--- a/src/renderer/icons.js
+++ b/src/renderer/icons.js
@@ -22,6 +22,9 @@ window.ccbudIcons = {
   settings: '',
 
   plus: '',
+  newSession: '',
+  send: '',
+  stop: '',
 
   chevronLeft: '',
 
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 84818a3..8a8b129 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -25,6 +25,10 @@
             
             服务
           
+          
           
+              
+              
+ +
+ +
+
+
+

开始一个新会话

+

在选定目录中运行编码智能体,请求经本机网关路由到当前服务。

+
+
+ 智能体 +
+ + +
+
+
+ 工作目录 +
+ + +
+
+
+ 权限 +
+ + + +
+
+
+ 任务 + +
+
+ + +
+
+ +
+ +
+ + +