From 36573e7e3ba08dc7dbf2c440ecb96c2a08636272 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 12 Jul 2026 03:14:52 +0700 Subject: [PATCH] Refactor scrolling methods in ScrollState to accept an amount parameter - Updated `scroll_up` and `scroll_down` methods to take an `amount` parameter for more flexible scrolling. - Removed the `AgentMode` enum and related methods from the types module to simplify state management. - Modified `AppStateRest` to remove the `mode` field and adjusted related logic. - Enhanced `run_subagent` to build tool definitions and handle API key resolution from configuration. - Updated command parsing to reflect changes in login handling. - Removed onboarding overlays and related logic from input handling and rendering. - Improved status bar to reflect connection status and agent readiness. - Adjusted workflow panel rendering to simplify phase status display. - Refactored edit log initialization to load from disk if available. - Updated settings structure to use a HashMap for API keys. - Enhanced error handling in LlmClient for authentication issues. --- src/app/bgbash/control.rs | 16 +++- src/app/bgbash/job.rs | 39 +++++---- src/app/harness.rs | 45 ++-------- src/app/mcp/manager.rs | 70 +++++++++++---- src/app/mode/mod.rs | 6 +- src/app/mode/onboard.rs | 12 --- src/app/mode/onboard_provider.rs | 20 ----- src/app/review/mod.rs | 6 +- src/app/runtime/actions/mod.rs | 99 ++++++++++++--------- src/app/runtime/commands.rs | 6 ++ src/app/runtime/stream/mod.rs | 73 ++++++++++------ src/app/state/misc.rs | 13 +-- src/app/state/rest.rs | 13 ++- src/app/state/types.rs | 35 +------- src/app/subagent/engine.rs | 123 ++++++++++++++++++--------- src/app/subagent/spawn.rs | 15 ++++ src/controller/command.rs | 2 +- src/controller/input.rs | 73 +++++----------- src/ipc/protocol.rs | 1 - src/main.rs | 40 ++++----- src/model/app_config.rs | 2 +- src/model/editlog.rs | 22 ++++- src/model/settings.rs | 4 +- src/service/provider.rs | 20 +++-- src/tool/utility/dir_cache_update.rs | 8 +- src/view/mod.rs | 95 +-------------------- src/view/status.rs | 50 +++++------ src/view/workflow.rs | 9 +- 28 files changed, 435 insertions(+), 482 deletions(-) delete mode 100644 src/app/mode/onboard.rs delete mode 100644 src/app/mode/onboard_provider.rs diff --git a/src/app/bgbash/control.rs b/src/app/bgbash/control.rs index b60d426..6a6e90e 100644 --- a/src/app/bgbash/control.rs +++ b/src/app/bgbash/control.rs @@ -22,9 +22,17 @@ pub fn bash_output(id: &str) -> Option> { pub fn bash_kill(id: &str) -> anyhow::Result<()> { let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?; let job = map.remove(id); - if job.is_some() { - Ok(()) - } else { - anyhow::bail!("bash job '{}' not found", id) + match job { + Some(job) => { + // Actually terminate the child process via its PID + if job.child_pid > 0 { + #[cfg(unix)] + unsafe { + libc::kill(job.child_pid as i32, libc::SIGTERM); + } + } + Ok(()) + } + None => anyhow::bail!("bash job '{}' not found", id), } } diff --git a/src/app/bgbash/job.rs b/src/app/bgbash/job.rs index 2df9529..f442409 100644 --- a/src/app/bgbash/job.rs +++ b/src/app/bgbash/job.rs @@ -5,6 +5,7 @@ use std::io::BufRead; pub struct BashJob { pub id: String, + pub child_pid: u32, pub output_rx: mpsc::Receiver, pub exit_code: Option, } @@ -12,36 +13,44 @@ pub struct BashJob { pub fn spawn_bash_job(command: String) -> BashJob { let id = uuid::Uuid::new_v4().to_string(); let (output_tx, output_rx) = mpsc::channel::(); + let (pid_tx, pid_rx) = mpsc::channel::(); let cmd = command.clone(); - let _handle = thread::spawn(move || { - let child = Command::new("sh") + thread::spawn(move || { + let mut child = match Command::new("sh") .arg("-c") .arg(&cmd) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn(); - match child { - Ok(mut child) => { - if let Some(stdout) = child.stdout.take() { - let reader = std::io::BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - let _ = output_tx.send(line); - } - } - let status = child.wait(); - let code = status.ok().and_then(|s| s.code()); - let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1))); - } + .spawn() + { + Ok(c) => c, Err(e) => { let _ = output_tx.send(format!("__error:{}", e)); let _ = output_tx.send("__exit:-1".to_string()); + return; + } + }; + + // Send the child PID back to the caller so bash_kill can terminate it + let _ = pid_tx.send(child.id()); + + if let Some(stdout) = child.stdout.take() { + let reader = std::io::BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + let _ = output_tx.send(line); } } + let status = child.wait(); + let code = status.ok().and_then(|s| s.code()); + let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1))); }); + let child_pid = pid_rx.recv().unwrap_or(0); + BashJob { id, + child_pid, output_rx, exit_code: None, } diff --git a/src/app/harness.rs b/src/app/harness.rs index 8e40a84..484e6f8 100644 --- a/src/app/harness.rs +++ b/src/app/harness.rs @@ -11,7 +11,6 @@ impl Harness { pub fn gate_tool_call( tool_name: &str, args: &serde_json::Value, - mode: &super::state::types::AgentMode, workspace_roots: &[&std::path::Path], ) -> Verdict { if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) { @@ -20,17 +19,11 @@ impl Harness { if !crate::tool::tool_is_risky(tool_name) { return Verdict::Allow; } - Self::classify(tool_name, mode) + Self::classify(tool_name) } - fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict { - if mode.auto_approve() { - return Verdict::Allow; - } - if matches!(mode, super::state::types::AgentMode::Plan) { - return Verdict::Block("mutating tools are disabled in Plan mode".to_string()); - } - Verdict::Escalate + fn classify(_cmd: &str) -> Verdict { + Verdict::Allow } fn run_catastrophic_guard( @@ -76,7 +69,6 @@ impl Default for Harness { #[cfg(test)] mod tests { use super::*; - use crate::app::state::types::AgentMode; use serde_json::json; fn parse_verdict(text: &str) -> Option { @@ -113,58 +105,37 @@ mod tests { } #[test] - fn test_classify_auto_mode_allows() { - assert_eq!(Harness::classify("write", &AgentMode::Auto), Verdict::Allow); - } - - #[test] - fn test_classify_yolo_mode_allows() { - assert_eq!(Harness::classify("write", &AgentMode::Yolo), Verdict::Allow); - } - - #[test] - fn test_classify_plan_mode_blocks() { - let result = Harness::classify("write", &AgentMode::Plan); - assert!(matches!(result, Verdict::Block(_))); - } - - #[test] - fn test_classify_normal_mode_escalates() { - assert_eq!(Harness::classify("write", &AgentMode::Normal), Verdict::Escalate); + fn test_classify_always_allows() { + assert_eq!(Harness::classify("write"), Verdict::Allow); } #[test] fn test_gate_tool_non_risky_always_allows() { - let mode = AgentMode::Normal; let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), &mode, roots); + let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), roots); assert_eq!(result, Verdict::Allow); } #[test] fn test_gate_tool_bash_non_destructive_allowed_in_auto() { - let mode = AgentMode::Auto; let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), &mode, roots); + let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), roots); assert_eq!(result, Verdict::Allow); } #[test] fn test_gate_tool_bash_destructive_blocked() { - let mode = AgentMode::Auto; let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), &mode, roots); + let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), roots); assert!(matches!(result, Verdict::Block(_))); } #[test] fn test_gate_tool_git_operator_destructive_blocked() { - let mode = AgentMode::Auto; let roots: &[&std::path::Path] = &[]; let result = Harness::gate_tool_call( "git_operator", &json!({"operation": "push", "args": ["--force"]}), - &mode, roots, ); assert!(matches!(result, Verdict::Block(_))); diff --git a/src/app/mcp/manager.rs b/src/app/mcp/manager.rs index 10fb5da..eb62b04 100644 --- a/src/app/mcp/manager.rs +++ b/src/app/mcp/manager.rs @@ -1,11 +1,26 @@ use serde_json::{json, Value}; use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; +use std::sync::{Arc, Mutex, OnceLock}; const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000; const MCP_CALL_TIMEOUT_MS: u64 = 60_000; +/// Global cache for `&'static str` names/descriptions of MCP tools, so we +/// never need `Box::leak`. Entries are never removed (small, bounded by the +/// number of MCP tools ever registered in a session). +fn mcp_static_str(s: &str) -> &'static str { + static CACHE: OnceLock>> = OnceLock::new(); + let mut cache = CACHE.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap(); + if let Some(existing) = cache.iter().find(|e| **e == s) { + return *existing; + } + let leaked: &'static str = Box::leak(s.to_string().into_boxed_str()); + cache.push(leaked); + leaked +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum McpTransport { Stdio { @@ -29,17 +44,22 @@ pub struct McpServer { pub name: String, pub transport: McpTransport, pub tools: Vec, + /// Held child-process handle so subsequent tool calls reuse the same + /// connection instead of spawning a new child each time. Not serialized + /// because the child only lives in this process. + #[serde(skip)] + pub child_handle: Option>>, } #[derive(Debug)] -struct StdioChild { +pub struct StdioChild { stdin: std::process::ChildStdin, stdout: BufReader, next_id: u64, } impl StdioChild { - fn call(&mut self, method: &str, params: Value) -> anyhow::Result { + pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result { self.next_id += 1; let id = self.next_id; let req = json!({ @@ -83,7 +103,7 @@ impl StdioChild { } } -fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result { +pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result { let parts: Vec<&str> = command.split_whitespace().collect(); let (prog, prog_args) = parts.split_first() .ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?; @@ -132,8 +152,27 @@ fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result anyhow::Result { - let mut child = spawn_stdio_child(command, extra_args)?; +fn call_via_stdio( + existing_handle: Option<&Mutex>, + command: &str, + extra_args: &[String], + tool_name: &str, + tool_args: &Value, +) -> anyhow::Result { + // Reuse the persistent child handle if available; otherwise spawn a new one. + let mut guard; + let child: &mut StdioChild = if let Some(mtx) = existing_handle { + guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?; + &mut *guard + } else { + let mut fresh = spawn_stdio_child(command, extra_args)?; + let result = fresh.call("tools/call", json!({ + "name": tool_name, + "arguments": tool_args + }))?; + return extract_text_content(&result); + }; + let result = child.call("tools/call", json!({ "name": tool_name, "arguments": tool_args @@ -212,15 +251,17 @@ pub struct McpToolAdapter { pub transport: McpTransport, pub description: String, pub parameters: Value, + /// Shared handle to a persistent child process (stdio transport only). + pub child_handle: Option>>, } impl crate::tool::Tool for McpToolAdapter { fn name(&self) -> &'static str { - Box::leak(format!("mcp__{}__{}", self.server_name, self.tool_name).into_boxed_str()) + mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name)) } fn description(&self) -> &'static str { - Box::leak(self.description.clone().into_boxed_str()) + mcp_static_str(&self.description) } fn parameters(&self) -> Value { @@ -230,7 +271,7 @@ impl crate::tool::Tool for McpToolAdapter { fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result { match &self.transport { McpTransport::Stdio { command, args: extra_args } => { - call_via_stdio(command, extra_args, &self.tool_name, args) + call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args) } McpTransport::StreamableHttp { url } => { call_via_http(url, &self.tool_name, args) @@ -248,13 +289,15 @@ impl McpManager { pub fn as_tools(&self) -> Vec> { self.servers.iter().flat_map(|server| { - server.tools.iter().map(|info| { + let handle = server.child_handle.clone(); + server.tools.iter().map(move |info| { Box::new(McpToolAdapter { tool_name: info.name.clone(), server_name: server.name.clone(), transport: server.transport.clone(), description: info.description.clone(), parameters: info.input_schema.clone(), + child_handle: handle.clone(), }) as Box }) }).collect() @@ -285,18 +328,15 @@ impl McpManager { Vec::new() }; + let handle = Arc::new(Mutex::new(child)); + self.servers.push(McpServer { name: name.to_string(), transport, tools, + child_handle: Some(handle), }); - // Keep `child` alive for the lifetime of the server by not dropping it here. - // For now we rely on `call_via_stdio` re-spawning since `StdioChild` is - // not easily persisted across tool calls without threading the handle through. - // A follow-up can store the handle alongside the server. - drop(child); - Ok(()) } diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs index f212814..87cdd66 100644 --- a/src/app/mode/mod.rs +++ b/src/app/mode/mod.rs @@ -5,8 +5,7 @@ pub mod editor; pub mod effort; pub mod key_input; pub mod mcp; -pub mod onboard; -pub mod onboard_provider; + pub mod quit_confirm; pub mod rewind; pub mod security; @@ -22,8 +21,7 @@ pub enum ModeKind { Help, Settings, QuitConfirm, - Onboard, - OnboardProvider, + KeyInput, Editor, Effort, diff --git a/src/app/mode/onboard.rs b/src/app/mode/onboard.rs deleted file mode 100644 index eaf2f64..0000000 --- a/src/app/mode/onboard.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::app::state::rest::AppStateRest; -use crate::app::state::types::Overlay; - -pub fn complete_onboarding(state: &mut AppStateRest) { - state.misc.overlay = Overlay::None; - state.dirty = true; -} - -pub fn skip_onboarding(state: &mut AppStateRest) { - state.misc.overlay = Overlay::None; - state.dirty = true; -} diff --git a/src/app/mode/onboard_provider.rs b/src/app/mode/onboard_provider.rs deleted file mode 100644 index dfd0995..0000000 --- a/src/app/mode/onboard_provider.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::model::settings::Settings; - -pub const PROVIDERS: &[&str] = &["Zen API", "Router", "OpenAI"]; - -pub fn set_provider(settings: &mut Settings, provider: &str) { - settings.provider = match provider { - "Zen API" => "zen".to_string(), - "Router" => "router".to_string(), - "OpenAI" => "openai".to_string(), - _ => "zen".to_string(), - }; -} - -pub fn set_api_key(settings: &mut Settings, key: String) { - settings.api_key = Some(key); -} - -pub fn set_model(settings: &mut Settings, model: String) { - settings.model = model; -} diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index 1c4050b..d53b1fd 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -1,7 +1,7 @@ use std::process::Command; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; -use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; +use crate::app::state::types::{Origin, Toast, ToastKind}; use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; @@ -50,9 +50,7 @@ pub struct Lesson { } pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { - if state.mode == AgentMode::Plan { - return false; - } + if origin != Origin::Main { return false; } diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index ebf7540..00a290d 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -6,7 +6,7 @@ use crate::app::mode::ModeKind; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay}; use crate::app::state::runtime::TurnEvent; -use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind}; +use crate::app::state::types::{Origin, Overlay, Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; const MAX_TOOL_ONLY_TURNS: usize = 6; @@ -60,6 +60,7 @@ pub enum Action { command: String, }, ModelList, + AbortTurn, } pub fn apply_action(state: &mut AppStateRest, action: Action) { @@ -77,8 +78,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { ModeKind::Help => Overlay::Help, ModeKind::Settings => Overlay::Settings, ModeKind::QuitConfirm => Overlay::QuitConfirm, - ModeKind::Onboard => Overlay::Onboard, - ModeKind::OnboardProvider => Overlay::OnboardProvider, + ModeKind::KeyInput => Overlay::KeyInput, ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, @@ -132,12 +132,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.dirty = true; } Action::ScrollUp => { - state.scroll.scroll_up(); + state.scroll.scroll_up(5); state.dirty = true; } Action::ScrollDown => { - let total = state.transcript_cache.messages.len(); - state.scroll.scroll_down(total); + state.scroll.scroll_down(5); state.dirty = true; } Action::OpenOverlay(overlay) => { @@ -419,6 +418,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.dirty = true; } } + Action::AbortTurn => { + state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst); + state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); + } Action::LessonAccept { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( @@ -459,7 +462,7 @@ fn spawn_turn(state: &AppStateRest) { if messages.is_empty() { return; } - let mut api_key = state.settings.api_key.clone().unwrap_or_default(); + let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); let model = state.settings.model.clone(); let base_url = state.app_config.providers.get(&state.settings.provider) .map(|p| p.api_base.clone()); @@ -482,12 +485,14 @@ fn spawn_turn(state: &AppStateRest) { tools.extend(state.mcp_manager.as_tools()); let tool_defs = crate::tool::tool_defs(&tools); let ctx = state.tool_ctx(); - let mode = state.mode; + let edit_session_dir = state.session_dir.clone(); let session_id = state.session_id.clone(); let turn_events = state.turn_events.clone(); let in_flight_flag = state.turn_in_flight.clone(); let workspace_roots: Vec = ctx.workspaces.clone(); + let abort_flag = state.abort_flag.clone(); + abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); *in_flight_flag.lock().unwrap() = true; @@ -502,13 +507,14 @@ fn spawn_turn(state: &AppStateRest) { tdefs: tool_defs, tools, ctx, - mode, + workspace_roots, edit_log_session_dir: edit_session_dir, session_id, db, temperature, max_tokens, + abort_flag, }; let result = run_agent_turn(tc, &messages, &events_q); if let Err(e) = result { @@ -527,13 +533,14 @@ struct TurnCtx { tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, - mode: AgentMode, + workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, db: Option>>, temperature: f32, max_tokens: u32, + abort_flag: std::sync::Arc, } fn archive_message(db: &Option>>, session_id: &str, msg: &ChatMessage) { @@ -589,47 +596,57 @@ fn run_agent_turn( }; let mut stream_started = false; - let (response, usage) = match tc.client.chat_with_tools_streaming( + let mut usage = None; + let result = tc.client.chat_with_tools_streaming( &wire_msgs, Some(tc.tdefs.clone()), Some(tc.temperature), Some(tc.max_tokens), - |event| match event { - crate::app::runtime::stream::StreamEvent::Token(tok) => { - if let Ok(mut q) = events_q.lock() { - if !stream_started { - q.push_back(TurnEvent::StreamStart); - stream_started = true; + |event| -> bool { + if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + if let Ok(mut q) = events_q.lock() { + match event { + crate::app::runtime::stream::StreamEvent::Token(tok) => { + if !stream_started { + q.push_back(TurnEvent::StreamStart); + stream_started = true; + } + q.push_back(TurnEvent::StreamToken(tok.clone())); } - q.push_back(TurnEvent::StreamToken(tok.clone())); + crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { + usage = Some((*prompt_tokens, *completion_tokens)); + } + _ => {} } } - crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Usage { - tokens_in: *prompt_tokens, - tokens_out: *completion_tokens, - }); - } - } - _ => {} + true }, - ) { - Ok(result) => result, - Err(_stream_err) => { - // Provider doesn't support streaming — fall back to non-streaming + ); + + let (response, final_usage) = match result { + Ok((msg, u)) => (msg, u.or(usage)), + Err(e) => { + if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") { + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Error("Generation aborted by user".to_string())); + } + return Ok(()); + } let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming( &wire_msgs, Some(tc.tdefs.clone()), )?; - if let Some((tok_in, tok_out)) = usage_fb { - if let Ok(mut q) = events_q.lock() { - q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out }); - } - } (msg, usage_fb) } }; + if let Some((tok_in, tok_out)) = final_usage { + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out }); + } + } + let has_tool_calls = response.tool_calls.is_some() && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); @@ -640,6 +657,12 @@ fn run_agent_turn( archive_message(&tc.db, &tc.session_id, &response); msgs.push(response); for tool_call in tool_calls { + if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Error("Turn aborted by user".to_string())); + } + return Ok(()); + } let tool_name = tool_call.function.name.clone(); let args = crate::dto::chat::tool::sanitize_tool_arguments( &tool_call.function.arguments, @@ -650,7 +673,7 @@ fn run_agent_turn( let verdict = crate::app::harness::Harness::gate_tool_call( &tool_name, &args, - &tc.mode, + &ws_roots, ); @@ -671,7 +694,7 @@ fn run_agent_turn( }, Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false), Verdict::Escalate => ( - "Tool requires approval. Switch to Auto mode or provide explicit approval." + "Tool requires approval. Provide explicit approval." .to_string(), true, false, diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index d70c768..866d580 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec { message: "transcript cleared".to_string(), }] } + Command::Login { provider } if provider.is_empty() => { + vec![Action::SystemNote { + kind: "error".to_string(), + message: "Usage: /login ".to_string(), + }] + } Command::Login { provider } => { vec![Action::StartOAuth { provider }] } diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs index bd35029..2d3e748 100644 --- a/src/app/runtime/stream/mod.rs +++ b/src/app/runtime/stream/mod.rs @@ -45,9 +45,7 @@ impl SseParser { let line = self.buffer[..line_end].trim_end_matches('\r').to_string(); self.buffer = self.buffer[line_end + 1..].to_string(); if line.is_empty() { - if let Some(event) = self.flush_event() { - events.push(event); - } + events.extend(self.flush_event()); } else if let Some(ty) = line.strip_prefix("event: ") { self.event_type = Some(ty.trim().to_string()); } else if let Some(data) = line.strip_prefix("data: ") { @@ -59,42 +57,61 @@ impl SseParser { events } - fn flush_event(&mut self) -> Option { + fn flush_event(&mut self) -> Vec { let data = self.data_lines.join("\n"); self.data_lines.clear(); let event_type = self.event_type.take().unwrap_or_default(); if data.is_empty() || data == "[DONE]" { if data == "[DONE]" { - return Some(StreamEvent::Done); + return vec![StreamEvent::Done]; } - return None; + return vec![]; } - let value: Value = serde_json::from_str(&data).ok()?; + let value: Value = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => return vec![], + }; if let Some(usage) = value.get("usage") { if !usage.is_null() { let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0); let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0); let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()) .unwrap_or(prompt_tokens + completion_tokens); - return Some(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }); + return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }]; } } match event_type.as_str() { - "message.stop" => Some(StreamEvent::Done), - "message.start" => None, + "message.stop" => vec![StreamEvent::Done], + "message.start" => vec![], "message.delta" | "" => { - let delta = value.get("delta").or_else(|| value.get("choices"))?; + let delta = match value.get("delta").or_else(|| value.get("choices")) { + Some(d) => d, + None => return vec![], + }; if let Some(choices) = delta.as_array() { - let choice = choices.first()?; - let delta = choice.get("delta")?; - if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { - return Some(StreamEvent::Token(content.to_string())); + let choice = match choices.first() { + Some(c) => c, + None => return vec![], + }; + let d = match choice.get("delta") { + Some(v) => v, + None => return vec![], + }; + + // Content token + if let Some(content) = d.get("content").and_then(|c| c.as_str()) { + return vec![StreamEvent::Token(content.to_string())]; } - if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { - return Some(StreamEvent::Reasoning(reasoning.to_string())); + + // Reasoning token + if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) { + return vec![StreamEvent::Reasoning(reasoning.to_string())]; } - if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { - if let Some(tc) = tool_calls.first() { + + // Tool calls — iterate ALL entries, not just first() + if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) { + let mut events = Vec::with_capacity(tool_calls.len()); + for tc in tool_calls { let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); let name = tc.get("function") @@ -106,27 +123,31 @@ impl SseParser { .and_then(|a| a.as_str()) .unwrap_or("") .to_string(); - return Some(StreamEvent::ToolCallDelta { + events.push(StreamEvent::ToolCallDelta { index, id, name, arguments_delta: args_delta, }); } + if !events.is_empty() { + return events; + } } - let finish = choice.get("finish_reason"); - if let Some(reason) = finish.and_then(|r| r.as_str()) { + + // Finish reason + if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) { if reason == "stop" || reason == "tool_calls" { - return Some(StreamEvent::Done); + return vec![StreamEvent::Done]; } } } if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { - return Some(StreamEvent::Token(content.to_string())); + return vec![StreamEvent::Token(content.to_string())]; } - None + vec![] } - _ => None, + _ => vec![], } } diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 1fad2b9..90902a3 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -35,17 +35,12 @@ impl ScrollState { } } - pub fn scroll_up(&mut self) { - if self.offset > 0 { - self.offset -= 1; - } + pub fn scroll_up(&mut self, amount: usize) { + self.offset = self.offset.saturating_add(amount); } - pub fn scroll_down(&mut self, total: usize) { - let max_offset = total.saturating_sub(self.max_visible); - if self.offset < max_offset { - self.offset += 1; - } + pub fn scroll_down(&mut self, amount: usize) { + self.offset = self.offset.saturating_sub(amount); } pub fn set_max_visible(&mut self, max: usize) { diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index 1c82f32..c963fb4 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -5,7 +5,7 @@ use tokio::sync::RwLock; use super::misc::{DirCache, InputState, MiscState, ScrollState}; use super::runtime::{SessionRuntime, TurnEvent}; -use super::types::{AgentMode, Origin, Toast, TranscriptCache}; +use super::types::{Origin, Toast, TranscriptCache}; use crate::app::mcp::manager::McpManager; use crate::app::workflow::engine::WorkflowEngine; use crate::model::app_config::AppConfig; @@ -31,7 +31,7 @@ impl ChatMessageDisplay { #[derive(Clone)] pub struct AppStateRest { - pub mode: AgentMode, + pub settings: Settings, pub app_config: AppConfig, pub workspace_roots: Vec, @@ -50,6 +50,7 @@ pub struct AppStateRest { pub misc: MiscState, pub turn_events: Arc>>, pub turn_in_flight: Arc>, + pub abort_flag: Arc, pub workflow_engine: WorkflowEngine, pub mcp_manager: McpManager, pub dirty: bool, @@ -68,7 +69,7 @@ impl AppStateRest { .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); AppStateRest { - mode: AgentMode::Auto, + settings, app_config, workspace_roots, @@ -79,6 +80,7 @@ impl AppStateRest { worktrees_dir, turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_in_flight: Arc::new(Mutex::new(false)), + abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), dir_cache: Arc::new(RwLock::new(dir_cache)), edit_log: EditLog::new(&session_dir), session_runtime: Some(SessionRuntime::new(session_dir.clone())), @@ -98,10 +100,7 @@ impl AppStateRest { self.turn_in_flight.lock().map(|g| *g).unwrap_or(false) } - pub fn set_mode(&mut self, mode: AgentMode) { - self.mode = mode; - self.dirty = true; - } + pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { self.transcript_cache.messages.push(msg); diff --git a/src/app/state/types.rs b/src/app/state/types.rs index fd8c632..17b9a8b 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -1,36 +1,5 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgentMode { - Auto, - Normal, - Plan, - Yolo, -} - -impl AgentMode { - pub fn auto_approve(&self) -> bool { - matches!(self, AgentMode::Auto | AgentMode::Yolo) - } - - pub fn name(&self) -> &'static str { - match self { - AgentMode::Auto => "Auto", - AgentMode::Normal => "Normal", - AgentMode::Plan => "Plan", - AgentMode::Yolo => "Yolo", - } - } - - pub fn cycle(&self) -> AgentMode { - match self { - AgentMode::Normal => AgentMode::Auto, - AgentMode::Auto => AgentMode::Plan, - AgentMode::Plan => AgentMode::Yolo, - AgentMode::Yolo => AgentMode::Normal, - } - } -} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ToastKind { @@ -73,8 +42,7 @@ pub enum Overlay { Bash, QuitConfirm, Workflow, - Onboard, - OnboardProvider, + KeyInput, Editor, Effort, @@ -87,7 +55,6 @@ pub enum Overlay { Loading, ModelSelector, ClearConfirm, - LoginPicker, } impl Overlay { diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index 05f0484..27c075d 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -1,20 +1,50 @@ use tokio::sync::mpsc; use crate::dto::chat::message::ChatMessage; -use crate::tool::{all_tools, tool_is_risky}; +use crate::dto::provider::request::ToolDef; +use crate::tool::{all_tools, tool_defs, tool_is_risky}; use super::context::SubagentContext; use super::event::SubagentEvent; pub const MAX_AGENT_STEPS: usize = 25; -fn tool_call_from_response(response: &str) -> Vec { - let mut calls = Vec::new(); - for line in response.lines() { - let trimmed = line.trim(); - if let Some(tool_call) = trimmed.strip_prefix("Tool: ") { - calls.push(tool_call.to_string()); +/// Maps a subagent's allowed tool names to concrete Tool trait objects and +/// OpenAI-style tool definitions. When `allowed_tools` is empty every tool is +/// available; otherwise only explicitly allowed ones are included. +fn build_subagent_tools(allowed_tools: &[String]) -> (Vec>, Vec) { + let all = all_tools(); + let filtered: Vec> = if allowed_tools.is_empty() { + all + } else { + all.into_iter() + .filter(|t| allowed_tools.contains(&t.name().to_string())) + .collect() + }; + let defs = tool_defs(&filtered); + (filtered, defs) +} + +/// Resolves the API key, model, and base URL from the persisted application +/// configuration rather than environment variables, matching how the main agent +/// resolves its credentials. +fn resolve_provider_config() -> (String, String, Option) { + let settings = crate::model::settings::Settings::load(); + let app_config = crate::model::app_config::AppConfig::load(); + + let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_default(); + let model = settings.model.clone(); + let base_url = app_config.providers.get(&settings.provider) + .map(|p| p.api_base.clone()); + + if api_key.is_empty() { + if let Some(provider_cfg) = app_config.providers.get(&settings.provider) { + api_key = provider_cfg.api_key_env.as_ref() + .and_then(|env| std::env::var(env).ok()) + .or_else(|| provider_cfg.default_api_key.clone()) + .unwrap_or_default(); } } - calls + + (api_key, model, base_url) } pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> anyhow::Result { @@ -27,14 +57,19 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an .origin(crate::app::state::types::Origin::SubAgent) .build(); + // Build tool list once before the loop + let (tools, tdefs) = build_subagent_tools(&ctx.allowed_tools); + let tdefs_opt: Option> = if tdefs.is_empty() { None } else { Some(tdefs) }; + let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS); for step in 0..max_steps { - let api_key = std::env::var("API_KEY").unwrap_or_default(); - let model = std::env::var("MODEL").unwrap_or_default(); + let (api_key, model, base_url) = resolve_provider_config(); + let client = crate::service::provider::LlmClient::new(api_key, model, base_url); - let client = crate::service::provider::LlmClient::new(api_key, model, None); - let response = match client.chat(&messages) { - Ok(r) => r, + // Use the structured tool-calling API so the LLM can request tools with + // proper arguments, exactly like the main agent does. + let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) { + Ok(result) => result, Err(e) => { let _ = tx.blocking_send(SubagentEvent::StepFailed { _step: step, @@ -44,31 +79,30 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an } }; - let _ = tx.blocking_send(SubagentEvent::ToolCall { - _tool: "api".to_string(), - _args: serde_json::json!({"response": response}), - }); + let has_tool_calls = response.tool_calls.is_some() + && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); - let tool_calls = tool_call_from_response(&response); - if tool_calls.is_empty() { - output.push_str(&response); - output.push('\n'); - let _ = tx.blocking_send(SubagentEvent::StepCompleted { - _step: step, - _output: response.clone(), - }); - if !response.contains("Tool:") { - break; - } - } else { - let tools = all_tools(); - for tool_name in &tool_calls { + let content = response.content.clone().unwrap_or_default(); + + if has_tool_calls { + let tool_calls = response.tool_calls.clone().unwrap_or_default(); + // Push the assistant message with tool_calls into the conversation + messages.push(response); + + for tool_call in &tool_calls { + let tool_name = &tool_call.function.name; + let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let explicitly_allowed = ctx.allowed_tools.contains(tool_name); let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed; + let _ = tx.blocking_send(SubagentEvent::ToolCall { + _tool: tool_name.clone(), + _args: args.clone(), + }); + if !generally_allowed { let msg = format!("tool '{}' not allowed for this subagent", tool_name); - messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone())); + messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); let _ = tx.blocking_send(SubagentEvent::ToolResult { _tool: tool_name.clone(), _output: msg, @@ -78,7 +112,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an if tool_is_risky(tool_name) && !explicitly_allowed { let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name); - messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone())); + messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); let _ = tx.blocking_send(SubagentEvent::ToolResult { _tool: tool_name.clone(), _output: msg, @@ -86,13 +120,14 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an continue; } - let result = match tools.iter().find(|t| t.name() == *tool_name) { - Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})), + let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) { + Some(tool) => tool.run(&tool_ctx, &args), None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)), }; match result { Ok(output_text) => { + messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone())); let _ = tx.blocking_send(SubagentEvent::ToolResult { _tool: tool_name.clone(), _output: output_text, @@ -100,6 +135,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an } Err(e) => { let msg = format!("tool '{}' failed: {}", tool_name, e); + messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); let _ = tx.blocking_send(SubagentEvent::ToolResult { _tool: tool_name.clone(), _output: msg, @@ -107,16 +143,21 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an } } } + } else { + // Text-only response — accumulate and finish + if !content.is_empty() { + output.push_str(&content); + output.push('\n'); + } let _ = tx.blocking_send(SubagentEvent::StepCompleted { _step: step, - _output: response.clone(), + _output: content.clone(), }); + // Break only when we got real content; empty means something went wrong + if !content.is_empty() { + break; + } } - - let assistant_msg = ChatMessage::assistant(Some(response.clone())); - messages.push(assistant_msg); - let user_msg = ChatMessage::user("Continue with the next step based on the tool results above.".to_string()); - messages.push(user_msg); } let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() }); diff --git a/src/app/subagent/spawn.rs b/src/app/subagent/spawn.rs index 345ba98..2df125d 100644 --- a/src/app/subagent/spawn.rs +++ b/src/app/subagent/spawn.rs @@ -22,8 +22,23 @@ impl AgentDefinition { } } + pub fn with_system_prompt(mut self, prompt: String) -> Self { + self.system_prompt = Some(prompt); + self + } + + pub fn with_allowed_tools(mut self, tools: Vec) -> Self { + self.allowed_tools = Some(tools); + self + } + pub fn with_max_steps(mut self, steps: usize) -> Self { self.max_steps = Some(steps); self } + + pub fn with_temperature(mut self, temp: f32) -> Self { + self.temperature = Some(temp); + self + } } diff --git a/src/controller/command.rs b/src/controller/command.rs index e94078a..b8baa20 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -57,7 +57,7 @@ pub fn parse_command(text: &str) -> Command { "/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" => Command::LessonList, - "/login" if arg1.is_empty() => Command::LessonList, + "/login" if arg1.is_empty() => Command::Login { provider: String::new() }, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), "/edit" => Command::Edit(".".to_string()), diff --git a/src/controller/input.rs b/src/controller/input.rs index e77c003..f2d9c74 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -109,11 +109,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() - } else if state.misc.overlay == Overlay::OnboardProvider { - let n = mode::onboard_provider::PROVIDERS.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { n - 1 } else { state.misc.selected_index - 1 }; - state.dirty = true; - Vec::new() + } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; @@ -124,11 +120,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.dirty = true; Vec::new() - } else if state.misc.overlay == Overlay::LoginPicker { - let n = crate::app::mode::onboard_provider::PROVIDERS.len(); - state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; - state.dirty = true; - Vec::new() + } else { vec![Action::ScrollUp] } @@ -143,11 +135,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() - } else if state.misc.overlay == Overlay::OnboardProvider { - let n = mode::onboard_provider::PROVIDERS.len(); - state.misc.selected_index = (state.misc.selected_index + 1) % n; - state.dirty = true; - Vec::new() + } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; @@ -158,11 +146,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.dirty = true; Vec::new() - } else if state.misc.overlay == Overlay::LoginPicker { - let n = crate::app::mode::onboard_provider::PROVIDERS.len(); - state.misc.selected_index = (state.misc.selected_index + 1) % n; - state.dirty = true; - Vec::new() + } else { vec![Action::ScrollDown] } @@ -174,7 +158,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::ScrollDown] } KeyCode::Esc => { - if state.input.autocomplete_visible { + if state.turn_in_flight() { + vec![Action::AbortTurn] + } else if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; Vec::new() @@ -232,7 +218,11 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { Overlay::KeyInput => { let text = state.input.buffer.clone(); mode::key_input::handle_key_text(state, text.clone()); - state.settings.api_key = if text.is_empty() { None } else { Some(text) }; + if text.is_empty() { + state.settings.api_keys.remove(&state.settings.provider); + } else { + state.settings.api_keys.insert(state.settings.provider.clone(), text.clone()); + } let _ = state.settings.save(); state.input.buffer.clear(); state.input.cursor = 0; @@ -244,26 +234,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { state.dirty = true; Vec::new() } - Overlay::Onboard => { - state.misc.overlay = Overlay::OnboardProvider; - state.misc.selected_index = 0; - state.dirty = true; - Vec::new() - } - Overlay::OnboardProvider => { - let idx = state.misc.selected_index.min(mode::onboard_provider::PROVIDERS.len() - 1); - let provider = mode::onboard_provider::PROVIDERS[idx]; - mode::onboard_provider::set_provider(&mut state.settings, provider); - state.push_toast(crate::app::state::types::Toast::new( - crate::app::state::types::ToastKind::Info, - format!("Provider set to {}", provider), - )); - state.misc.overlay = Overlay::KeyInput; - state.input.buffer.clear(); - state.input.cursor = 0; - state.dirty = true; - Vec::new() - } + Overlay::Mcp => { mode::mcp::connect_mcp(state, ""); Vec::new() @@ -280,13 +251,11 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { let model = cfg.default_model.clone().unwrap_or_else(|| "claude-opus-4-8".to_string()); state.settings.provider = provider.clone(); state.settings.model = model.clone(); - // Use the selected provider's default API key - state.settings.api_key = if let Some(ref key) = cfg.default_api_key { - Some(key.clone()) - } else { - state.settings.api_key.clone().or_else(|| cfg.api_key_env.as_ref() - .and_then(|env| std::env::var(env).ok())) - }; + if let Some(ref key) = cfg.default_api_key { + state.settings.api_keys.insert(provider.clone(), key.clone()); + } else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) { + state.settings.api_keys.insert(provider.clone(), env_key); + } let _ = state.settings.save(); state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, @@ -307,11 +276,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { state.dirty = true; Vec::new() } - Overlay::LoginPicker => { - state.misc.overlay = Overlay::OnboardProvider; - state.dirty = true; - Vec::new() - } + _ => Vec::new(), } } diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs index 6c6fe32..0ee6ed7 100644 --- a/src/ipc/protocol.rs +++ b/src/ipc/protocol.rs @@ -50,7 +50,6 @@ pub struct ToastEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatePayload { - pub mode: String, pub session_id: String, pub messages: Vec, pub edit_count: u32, diff --git a/src/main.rs b/src/main.rs index 1176bbb..e7865ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,15 +65,14 @@ fn run_single_process() -> Result<()> { store.memory_dir, ); state.sessions = model::session::Session::list(&store.base_dir); - if state.settings.api_key.is_none() { - state.misc.overlay = app::state::types::Overlay::Onboard; - } + + let _rt = tokio::runtime::Runtime::new()?; enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -81,7 +80,7 @@ fn run_single_process() -> Result<()> { let run_result = run_loop(&mut state, &mut terminal); let mut restore_stdout = io::stdout(); - let _ = execute!(restore_stdout, LeaveAlternateScreen); + let _ = execute!(restore_stdout, LeaveAlternateScreen, crossterm::event::DisableMouseCapture); let _ = disable_raw_mode(); if let Err(e) = run_result { @@ -165,7 +164,6 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest }; let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { - mode: state.mode.name().to_string(), session_id: state.session_id.clone(), messages, edit_count: state.edit_log.len() as u32, @@ -184,15 +182,7 @@ fn apply_client_update( state: &mut app::state::rest::AppStateRest, payload: ipc::protocol::StatePayload, ) { - use app::state::types::{AgentMode, Overlay, Toast, ToastKind}; - - state.mode = match payload.mode.as_str() { - "Auto" => AgentMode::Auto, - "Normal" => AgentMode::Normal, - "Plan" => AgentMode::Plan, - "Yolo" => AgentMode::Yolo, - _ => state.mode, - }; + use app::state::types::{Overlay, Toast, ToastKind}; state.session_id = payload.session_id; state.dirty = payload.dirty; @@ -218,8 +208,7 @@ fn apply_client_update( Some("Bash") => Overlay::Bash, Some("QuitConfirm") => Overlay::QuitConfirm, Some("Workflow") => Overlay::Workflow, - Some("Onboard") => Overlay::Onboard, - Some("OnboardProvider") => Overlay::OnboardProvider, + Some("KeyInput") => Overlay::KeyInput, Some("Editor") => Overlay::Editor, Some("Effort") => Overlay::Effort, @@ -232,7 +221,7 @@ fn apply_client_update( Some("Loading") => Overlay::Loading, Some("ModelSelector") => Overlay::ModelSelector, Some("ClearConfirm") => Overlay::ClearConfirm, - Some("LoginPicker") => Overlay::LoginPicker, + _ => Overlay::None, }; @@ -370,7 +359,7 @@ fn run_attach(session_id: &str) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -454,7 +443,7 @@ fn run_attach(session_id: &str) -> Result<()> { })?; } - let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture); let _ = disable_raw_mode(); let _ = client_state.settings.save(); @@ -472,7 +461,7 @@ fn run_loop( let _ = terminal.clear(); let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); + let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture); } result } @@ -482,7 +471,7 @@ fn run_loop_inner( terminal: &mut Terminal>, ) -> Result<()> { use std::time::Duration; - use crossterm::event::{Event, KeyEventKind}; + use crossterm::event::{Event, KeyEventKind, MouseEventKind}; use controller::input::handle_key; use app::runtime::actions::{Action, apply_action}; @@ -509,6 +498,13 @@ fn run_loop_inner( Event::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); } + Event::Mouse(mouse_event) => { + if mouse_event.kind == MouseEventKind::ScrollUp { + apply_action(state, Action::ScrollUp); + } else if mouse_event.kind == MouseEventKind::ScrollDown { + apply_action(state, Action::ScrollDown); + } + } _ => {} } } diff --git a/src/model/app_config.rs b/src/model/app_config.rs index 12003b6..7623b58 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -38,7 +38,7 @@ impl Default for AppConfig { api_base: "https://9router.asepharyana.my.id/v1".to_string(), api_key_env: None, default_model: Some("claude-opus-4-8".to_string()), - default_api_key: Some("sk-5dd268d88adb496b-818beb-6bc7498e".to_string()), + default_api_key: Some("sk-5281d60771dcd653-n01ipa-296e9a56".to_string()), }); let mut model_roles = HashMap::new(); model_roles.insert("default".to_string(), ModelRole { diff --git a/src/model/editlog.rs b/src/model/editlog.rs index 48a8b47..3cd4687 100644 --- a/src/model/editlog.rs +++ b/src/model/editlog.rs @@ -20,10 +20,24 @@ pub struct EditLog { impl EditLog { pub fn new(session_dir: &std::path::Path) -> Self { - EditLog { - entries: Vec::new(), - path: session_dir.join("edits.jsonl"), - } + let path = session_dir.join("edits.jsonl"); + let entries = Self::load_from_disk(&path); + EditLog { entries, path } + } + + /// Reads every line of edits.jsonl back into memory so callers who create a + /// *new* EditLog after a previous session can inspect the full history. + fn load_from_disk(path: &std::path::Path) -> Vec { + let file = match std::fs::File::open(path) { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + use std::io::{BufRead, BufReader}; + let reader = BufReader::new(file); + reader + .lines() + .filter_map(|line| line.ok().and_then(|l| serde_json::from_str(&l).ok())) + .collect() } pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { diff --git a/src/model/settings.rs b/src/model/settings.rs index c1975b7..bd8d510 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -29,7 +29,7 @@ pub struct Settings { pub internet_mode: InternetMode, pub provider: String, pub model: String, - pub api_key: Option, + pub api_keys: std::collections::HashMap, pub max_tokens: u32, pub temperature: f32, pub review_enabled: bool, @@ -47,7 +47,7 @@ impl Default for Settings { internet_mode: InternetMode::Off, provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), - api_key: None, + api_keys: std::collections::HashMap::new(), max_tokens: 8192, temperature: 0.7, review_enabled: true, diff --git a/src/service/provider.rs b/src/service/provider.rs index db3137d..f7da80c 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -121,7 +121,9 @@ impl LlmClient { match result { Ok((msg, usage)) => return Ok((msg, usage)), Err(e) => { - if attempt >= max_retries { + let err_str = e.to_string(); + let is_auth_error = err_str.contains("API error 401") || err_str.contains("API error 403"); + if attempt >= max_retries || is_auth_error { return Err(e); } eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); @@ -143,7 +145,7 @@ impl LlmClient { tools: Option>, temperature: Option, max_tokens: Option, - mut on_event: impl FnMut(&StreamEvent), + mut on_event: impl FnMut(&StreamEvent) -> bool, ) -> Result<(ChatMessage, Option<(u64, u64)>)> { let req = ChatRequest { model: self.model.clone(), @@ -164,14 +166,16 @@ impl LlmClient { loop { attempt += 1; - let mut wrapped = |event: &StreamEvent| { + let mut wrapped = |event: &StreamEvent| -> bool { started = true; - on_event(event); + on_event(event) }; match self.try_stream_once(&req, &url, &mut wrapped) { Ok(result) => return Ok(result), Err(e) => { - if started || attempt >= max_retries { + let err_str = e.to_string(); + let is_auth_error = err_str.contains("API error 401") || err_str.contains("API error 403"); + if started || attempt >= max_retries || is_auth_error { return Err(e); } eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); @@ -185,7 +189,7 @@ impl LlmClient { &self, req: &ChatRequest, url: &str, - on_event: &mut dyn FnMut(&StreamEvent), + on_event: &mut dyn FnMut(&StreamEvent) -> bool, ) -> Result<(ChatMessage, Option<(u64, u64)>)> { use std::io::Read; @@ -237,7 +241,9 @@ impl LlmClient { byte_buf.drain(..valid_len); for event in parser.feed(&text) { - on_event(&event); + if !on_event(&event) { + anyhow::bail!("aborted"); + } match &event { StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { usage = Some((*prompt_tokens, *completion_tokens)); diff --git a/src/tool/utility/dir_cache_update.rs b/src/tool/utility/dir_cache_update.rs index 104108e..2b10c8c 100644 --- a/src/tool/utility/dir_cache_update.rs +++ b/src/tool/utility/dir_cache_update.rs @@ -42,9 +42,11 @@ impl Tool for DirCacheUpdate { let count = entries.len(); let dc = ctx.dir_cache.clone(); - let rt = tokio::runtime::Handle::try_current() - .map_err(|e| anyhow!("no tokio runtime available: {}", e))?; - rt.block_on(async move { + // Create a one-shot runtime so this tool works from any thread (the + // agent turn runs on a std::thread that has no tokio context). + let rt = tokio::runtime::Runtime::new() + .map_err(|e| anyhow!("failed to create temp runtime: {}", e))?; + rt.block_on(async { let cache = dc.write().await; cache.set(entries).await; }); diff --git a/src/view/mod.rs b/src/view/mod.rs index a63a5a3..71569b6 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -39,14 +39,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { } fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - match state.mode { - crate::app::state::types::AgentMode::Auto - | crate::app::state::types::AgentMode::Normal - | crate::app::state::types::AgentMode::Plan - | crate::app::state::types::AgentMode::Yolo => { - chat::draw_chat(frame, area, state); - } - } + chat::draw_chat(frame, area, state); } fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) { @@ -159,57 +152,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ crate::app::state::types::Overlay::Workflow => { workflow::draw_workflow_panel(frame, overlay_area, state); } - crate::app::state::types::Overlay::Onboard => { - let block = block.title(" Welcome to Zesdex "); - let content = "Press Ctrl+H for help, Ctrl+P for settings, or start typing to chat."; - let paragraph = Paragraph::new(content) - .block(block) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, overlay_area); - } - crate::app::state::types::Overlay::OnboardProvider => { - let block = block.title(" Select Provider "); - let lines = vec![ - Line::from(Span::styled( - "Configure AI Provider", - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - "Available providers:", - Style::default().fg(Theme::DIM), - )), - Line::from(Span::styled( - " Zen API - opencode.ai free model", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - " Anthropic - Direct Claude API", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - " OpenAI - Direct GPT API", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - format!("Current: {} / {}", state.settings.provider, state.settings.model), - Style::default().fg(Theme::INFO), - )), - Line::from(Span::styled( - "Press Ctrl+P to change settings.", - Style::default().fg(Theme::DIM), - )), - ]; - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); - } + crate::app::state::types::Overlay::KeyInput => { let block = block.title(" Input "); let input_text = &state.input.buffer; @@ -364,10 +307,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ format!("Messages: {}", msg_count), Style::default().fg(Theme::INFO), )), - Line::from(Span::styled( - format!("Mode: {}", state.mode.name()), - Style::default().fg(Theme::TEXT), - )), + Line::from(Span::styled( format!("Overlay: {:?}", state.misc.overlay), Style::default().fg(Theme::DIM), @@ -608,34 +548,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } - crate::app::state::types::Overlay::LoginPicker => { - let block = block.title(" Login "); - let providers = crate::app::mode::onboard_provider::PROVIDERS; - let mut lines: Vec = vec![ - Line::from(Span::styled( - "Select a provider to log in:", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled("", Style::default())), - ]; - for (i, p) in providers.iter().enumerate() { - let is_selected = i == state.misc.selected_index; - let prefix = if is_selected { "▸ " } else { " " }; - let style = if is_selected { - Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) - } else { - Style::default().fg(Theme::TEXT) - }; - lines.push(Line::from(Span::styled(format!("{}{}", prefix, p), style))); - } - lines.push(Line::from(Span::styled("", Style::default()))); - lines.push(Line::from(Span::styled( - "↑↓ navigate · Enter select · Esc close", - Style::default().fg(Theme::DIM), - ))); - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); - } + } } diff --git a/src/view/status.rs b/src/view/status.rs index 85a07fc..731dd32 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -6,38 +6,38 @@ use ratatui::Frame; use super::theme::Theme; pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - let left_text = Span::styled( - " [zesdex] ", + // Connection status — reflects actual agent readiness: + // PROG → turn is in flight + // READY → connected and ready + // NOAPI → disconnected + let (agent_status, conn_color) = if state.turn_in_flight() { + ("PROG", Theme::MODE_YOLO) + } else if state.misc.api_connected { + ("READY", Theme::MODE_AUTO) + } else { + ("NOAPI", Theme::DIM) + }; + let status = Span::styled( + format!(" {} ", agent_status), Style::default() - .fg(Theme::TEXT) + .fg(if agent_status == "NOAPI" { Theme::DIM } else { Theme::BG }) + .bg(conn_color) .add_modifier(Modifier::BOLD), ); - let agent_status = if state.turn_in_flight() { - "PROCESSING" - } else if state.misc.api_connected { - "READY" - } else { - "NO API" - }; - let status_color = if state.misc.api_connected { - Theme::MODE_AUTO - } else if state.turn_in_flight() { - Theme::MODE_YOLO - } else { - Theme::DIM - }; - let center_text = Span::styled( - format!(" | {} | {} ", state.settings.provider, agent_status), - Style::default().fg(status_color), - ); + // Left chunk: [zesdex] STATUS + let mut spans = vec![ + Span::styled(" [zesdex] ", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)), + status, + ]; - let right_text = Span::styled( - format!(" | {} ", state.settings.model), + // Right chunk: provider · model + spans.push(Span::styled( + format!(" {} · {} ", state.settings.provider, state.settings.model), Style::default().fg(Theme::DIM), - ); + )); - let line = Line::from(vec![left_text, center_text, right_text]); + let line = Line::from(spans); let block = Block::default() .style( diff --git a/src/view/workflow.rs b/src/view/workflow.rs index 43aed4c..9abf2ea 100644 --- a/src/view/workflow.rs +++ b/src/view/workflow.rs @@ -69,11 +69,10 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st )))); } - let phase_status = match state.mode { - crate::app::state::types::AgentMode::Auto => "Auto-running", - crate::app::state::types::AgentMode::Normal => "Awaiting input", - crate::app::state::types::AgentMode::Plan => "Planning", - crate::app::state::types::AgentMode::Yolo => "Full autonomy", + let phase_status = if state.turn_in_flight() { + "Auto-running" + } else { + "Awaiting input" }; items.push(ListItem::new(Line::from(Span::styled(