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.
This commit is contained in:
asepharyana
2026-07-12 03:14:52 +07:00
parent 71d3494372
commit 36573e7e3b
28 changed files with 435 additions and 482 deletions
+12 -4
View File
@@ -22,9 +22,17 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
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),
}
}
+24 -15
View File
@@ -5,6 +5,7 @@ use std::io::BufRead;
pub struct BashJob {
pub id: String,
pub child_pid: u32,
pub output_rx: mpsc::Receiver<String>,
pub exit_code: Option<i32>,
}
@@ -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::<String>();
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
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,
}
+8 -37
View File
@@ -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<Verdict> {
@@ -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(_)));
+55 -15
View File
@@ -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<Mutex<Vec<&'static str>>> = 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<McpToolInfo>,
/// 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<Arc<Mutex<StdioChild>>>,
}
#[derive(Debug)]
struct StdioChild {
pub struct StdioChild {
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
}
impl StdioChild {
fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
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<StdioChild> {
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
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<Std
Ok(mcp)
}
fn call_via_stdio(command: &str, extra_args: &[String], tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
let mut child = spawn_stdio_child(command, extra_args)?;
fn call_via_stdio(
existing_handle: Option<&Mutex<StdioChild>>,
command: &str,
extra_args: &[String],
tool_name: &str,
tool_args: &Value,
) -> anyhow::Result<String> {
// 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<Arc<Mutex<StdioChild>>>,
}
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<String> {
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<Box<dyn crate::tool::Tool>> {
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<dyn crate::tool::Tool>
})
}).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(())
}
+2 -4
View File
@@ -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,
-12
View File
@@ -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;
}
-20
View File
@@ -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;
}
+2 -4
View File
@@ -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;
}
+61 -38
View File
@@ -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<std::path::PathBuf> = 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<crate::dto::provider::request::ToolDef>,
tools: Vec<Box<dyn crate::tool::Tool>>,
ctx: crate::tool::ToolCtx,
mode: AgentMode,
workspace_roots: Vec<std::path::PathBuf>,
edit_log_session_dir: std::path::PathBuf,
session_id: String,
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
temperature: f32,
max_tokens: u32,
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, 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,
+6
View File
@@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
message: "transcript cleared".to_string(),
}]
}
Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote {
kind: "error".to_string(),
message: "Usage: /login <provider>".to_string(),
}]
}
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
+47 -26
View File
@@ -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<StreamEvent> {
fn flush_event(&mut self) -> Vec<StreamEvent> {
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![],
}
}
+4 -9
View File
@@ -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) {
+6 -7
View File
@@ -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<PathBuf>,
@@ -50,6 +50,7 @@ pub struct AppStateRest {
pub misc: MiscState,
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
pub turn_in_flight: Arc<Mutex<bool>>,
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
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);
+1 -34
View File
@@ -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 {
+82 -41
View File
@@ -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<String> {
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<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = 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<String>) {
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<SubagentEvent>) -> anyhow::Result<String> {
@@ -27,14 +57,19 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> 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<Vec<ToolDef>> = 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<SubagentEvent>) -> 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<SubagentEvent>) -> 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<SubagentEvent>) -> 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<SubagentEvent>) -> 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<SubagentEvent>) -> 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() });
+15
View File
@@ -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<String>) -> 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
}
}