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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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() });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+19
-54
@@ -109,11 +109,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
} 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<Action> {
|
||||
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<Action> {
|
||||
} 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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
state.dirty = true;
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::LoginPicker => {
|
||||
state.misc.overlay = Overlay::OnboardProvider;
|
||||
state.dirty = true;
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MessageEntry>,
|
||||
pub edit_count: u32,
|
||||
|
||||
+18
-22
@@ -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<CrosstermBackend<io::Stdout>>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+18
-4
@@ -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<EditLogEntry> {
|
||||
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<()> {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub api_keys: std::collections::HashMap<String, String>,
|
||||
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,
|
||||
|
||||
+13
-7
@@ -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<Vec<ToolDef>>,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
+4
-91
@@ -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<Line> = 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-25
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user