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<()> {
|
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||||
let job = map.remove(id);
|
let job = map.remove(id);
|
||||||
if job.is_some() {
|
match job {
|
||||||
Ok(())
|
Some(job) => {
|
||||||
} else {
|
// Actually terminate the child process via its PID
|
||||||
anyhow::bail!("bash job '{}' not found", id)
|
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 struct BashJob {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub child_pid: u32,
|
||||||
pub output_rx: mpsc::Receiver<String>,
|
pub output_rx: mpsc::Receiver<String>,
|
||||||
pub exit_code: Option<i32>,
|
pub exit_code: Option<i32>,
|
||||||
}
|
}
|
||||||
@@ -12,36 +13,44 @@ pub struct BashJob {
|
|||||||
pub fn spawn_bash_job(command: String) -> BashJob {
|
pub fn spawn_bash_job(command: String) -> BashJob {
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let (output_tx, output_rx) = mpsc::channel::<String>();
|
let (output_tx, output_rx) = mpsc::channel::<String>();
|
||||||
|
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
|
||||||
let cmd = command.clone();
|
let cmd = command.clone();
|
||||||
|
|
||||||
let _handle = thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let child = Command::new("sh")
|
let mut child = match Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(&cmd)
|
.arg(&cmd)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn();
|
.spawn()
|
||||||
match child {
|
{
|
||||||
Ok(mut child) => {
|
Ok(c) => c,
|
||||||
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)));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = output_tx.send(format!("__error:{}", e));
|
let _ = output_tx.send(format!("__error:{}", e));
|
||||||
let _ = output_tx.send("__exit:-1".to_string());
|
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 {
|
BashJob {
|
||||||
id,
|
id,
|
||||||
|
child_pid,
|
||||||
output_rx,
|
output_rx,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-37
@@ -11,7 +11,6 @@ impl Harness {
|
|||||||
pub fn gate_tool_call(
|
pub fn gate_tool_call(
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
args: &serde_json::Value,
|
args: &serde_json::Value,
|
||||||
mode: &super::state::types::AgentMode,
|
|
||||||
workspace_roots: &[&std::path::Path],
|
workspace_roots: &[&std::path::Path],
|
||||||
) -> Verdict {
|
) -> Verdict {
|
||||||
if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) {
|
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) {
|
if !crate::tool::tool_is_risky(tool_name) {
|
||||||
return Verdict::Allow;
|
return Verdict::Allow;
|
||||||
}
|
}
|
||||||
Self::classify(tool_name, mode)
|
Self::classify(tool_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
|
fn classify(_cmd: &str) -> Verdict {
|
||||||
if mode.auto_approve() {
|
Verdict::Allow
|
||||||
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 run_catastrophic_guard(
|
fn run_catastrophic_guard(
|
||||||
@@ -76,7 +69,6 @@ impl Default for Harness {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app::state::types::AgentMode;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn parse_verdict(text: &str) -> Option<Verdict> {
|
fn parse_verdict(text: &str) -> Option<Verdict> {
|
||||||
@@ -113,58 +105,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_classify_auto_mode_allows() {
|
fn test_classify_always_allows() {
|
||||||
assert_eq!(Harness::classify("write", &AgentMode::Auto), Verdict::Allow);
|
assert_eq!(Harness::classify("write"), 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gate_tool_non_risky_always_allows() {
|
fn test_gate_tool_non_risky_always_allows() {
|
||||||
let mode = AgentMode::Normal;
|
|
||||||
let roots: &[&std::path::Path] = &[];
|
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);
|
assert_eq!(result, Verdict::Allow);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gate_tool_bash_non_destructive_allowed_in_auto() {
|
fn test_gate_tool_bash_non_destructive_allowed_in_auto() {
|
||||||
let mode = AgentMode::Auto;
|
|
||||||
let roots: &[&std::path::Path] = &[];
|
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);
|
assert_eq!(result, Verdict::Allow);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gate_tool_bash_destructive_blocked() {
|
fn test_gate_tool_bash_destructive_blocked() {
|
||||||
let mode = AgentMode::Auto;
|
|
||||||
let roots: &[&std::path::Path] = &[];
|
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(_)));
|
assert!(matches!(result, Verdict::Block(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gate_tool_git_operator_destructive_blocked() {
|
fn test_gate_tool_git_operator_destructive_blocked() {
|
||||||
let mode = AgentMode::Auto;
|
|
||||||
let roots: &[&std::path::Path] = &[];
|
let roots: &[&std::path::Path] = &[];
|
||||||
let result = Harness::gate_tool_call(
|
let result = Harness::gate_tool_call(
|
||||||
"git_operator",
|
"git_operator",
|
||||||
&json!({"operation": "push", "args": ["--force"]}),
|
&json!({"operation": "push", "args": ["--force"]}),
|
||||||
&mode,
|
|
||||||
roots,
|
roots,
|
||||||
);
|
);
|
||||||
assert!(matches!(result, Verdict::Block(_)));
|
assert!(matches!(result, Verdict::Block(_)));
|
||||||
|
|||||||
+55
-15
@@ -1,11 +1,26 @@
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
|
||||||
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
||||||
const MCP_CALL_TIMEOUT_MS: u64 = 60_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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum McpTransport {
|
pub enum McpTransport {
|
||||||
Stdio {
|
Stdio {
|
||||||
@@ -29,17 +44,22 @@ pub struct McpServer {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub transport: McpTransport,
|
pub transport: McpTransport,
|
||||||
pub tools: Vec<McpToolInfo>,
|
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)]
|
#[derive(Debug)]
|
||||||
struct StdioChild {
|
pub struct StdioChild {
|
||||||
stdin: std::process::ChildStdin,
|
stdin: std::process::ChildStdin,
|
||||||
stdout: BufReader<std::process::ChildStdout>,
|
stdout: BufReader<std::process::ChildStdout>,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StdioChild {
|
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;
|
self.next_id += 1;
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
let req = json!({
|
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 parts: Vec<&str> = command.split_whitespace().collect();
|
||||||
let (prog, prog_args) = parts.split_first()
|
let (prog, prog_args) = parts.split_first()
|
||||||
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
.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)
|
Ok(mcp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_via_stdio(command: &str, extra_args: &[String], tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
fn call_via_stdio(
|
||||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
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!({
|
let result = child.call("tools/call", json!({
|
||||||
"name": tool_name,
|
"name": tool_name,
|
||||||
"arguments": tool_args
|
"arguments": tool_args
|
||||||
@@ -212,15 +251,17 @@ pub struct McpToolAdapter {
|
|||||||
pub transport: McpTransport,
|
pub transport: McpTransport,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub parameters: Value,
|
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 {
|
impl crate::tool::Tool for McpToolAdapter {
|
||||||
fn name(&self) -> &'static str {
|
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 {
|
fn description(&self) -> &'static str {
|
||||||
Box::leak(self.description.clone().into_boxed_str())
|
mcp_static_str(&self.description)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters(&self) -> Value {
|
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> {
|
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||||
match &self.transport {
|
match &self.transport {
|
||||||
McpTransport::Stdio { command, args: extra_args } => {
|
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 } => {
|
McpTransport::StreamableHttp { url } => {
|
||||||
call_via_http(url, &self.tool_name, args)
|
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>> {
|
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||||
self.servers.iter().flat_map(|server| {
|
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 {
|
Box::new(McpToolAdapter {
|
||||||
tool_name: info.name.clone(),
|
tool_name: info.name.clone(),
|
||||||
server_name: server.name.clone(),
|
server_name: server.name.clone(),
|
||||||
transport: server.transport.clone(),
|
transport: server.transport.clone(),
|
||||||
description: info.description.clone(),
|
description: info.description.clone(),
|
||||||
parameters: info.input_schema.clone(),
|
parameters: info.input_schema.clone(),
|
||||||
|
child_handle: handle.clone(),
|
||||||
}) as Box<dyn crate::tool::Tool>
|
}) as Box<dyn crate::tool::Tool>
|
||||||
})
|
})
|
||||||
}).collect()
|
}).collect()
|
||||||
@@ -285,18 +328,15 @@ impl McpManager {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let handle = Arc::new(Mutex::new(child));
|
||||||
|
|
||||||
self.servers.push(McpServer {
|
self.servers.push(McpServer {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
transport,
|
transport,
|
||||||
tools,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -5,8 +5,7 @@ pub mod editor;
|
|||||||
pub mod effort;
|
pub mod effort;
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod onboard;
|
|
||||||
pub mod onboard_provider;
|
|
||||||
pub mod quit_confirm;
|
pub mod quit_confirm;
|
||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
@@ -22,8 +21,7 @@ pub enum ModeKind {
|
|||||||
Help,
|
Help,
|
||||||
Settings,
|
Settings,
|
||||||
QuitConfirm,
|
QuitConfirm,
|
||||||
Onboard,
|
|
||||||
OnboardProvider,
|
|
||||||
KeyInput,
|
KeyInput,
|
||||||
Editor,
|
Editor,
|
||||||
Effort,
|
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 std::process::Command;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::runtime::TurnEvent;
|
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::context::build_subagent_context;
|
||||||
use crate::app::subagent::engine::run_subagent;
|
use crate::app::subagent::engine::run_subagent;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
@@ -50,9 +50,7 @@ pub struct Lesson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||||
if state.mode == AgentMode::Plan {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if origin != Origin::Main {
|
if origin != Origin::Main {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::app::mode::ModeKind;
|
|||||||
use crate::app::review::{should_trigger_review, trigger_review};
|
use crate::app::review::{should_trigger_review, trigger_review};
|
||||||
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
||||||
use crate::app::state::runtime::TurnEvent;
|
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};
|
use crate::dto::chat::message::{ChatMessage, Role};
|
||||||
|
|
||||||
const MAX_TOOL_ONLY_TURNS: usize = 6;
|
const MAX_TOOL_ONLY_TURNS: usize = 6;
|
||||||
@@ -60,6 +60,7 @@ pub enum Action {
|
|||||||
command: String,
|
command: String,
|
||||||
},
|
},
|
||||||
ModelList,
|
ModelList,
|
||||||
|
AbortTurn,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
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::Help => Overlay::Help,
|
||||||
ModeKind::Settings => Overlay::Settings,
|
ModeKind::Settings => Overlay::Settings,
|
||||||
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
||||||
ModeKind::Onboard => Overlay::Onboard,
|
|
||||||
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
|
||||||
ModeKind::KeyInput => Overlay::KeyInput,
|
ModeKind::KeyInput => Overlay::KeyInput,
|
||||||
ModeKind::Editor => Overlay::Editor,
|
ModeKind::Editor => Overlay::Editor,
|
||||||
ModeKind::Effort => Overlay::Effort,
|
ModeKind::Effort => Overlay::Effort,
|
||||||
@@ -132,12 +132,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::ScrollUp => {
|
Action::ScrollUp => {
|
||||||
state.scroll.scroll_up();
|
state.scroll.scroll_up(5);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::ScrollDown => {
|
Action::ScrollDown => {
|
||||||
let total = state.transcript_cache.messages.len();
|
state.scroll.scroll_down(5);
|
||||||
state.scroll.scroll_down(total);
|
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
Action::OpenOverlay(overlay) => {
|
Action::OpenOverlay(overlay) => {
|
||||||
@@ -419,6 +418,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
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 } => {
|
Action::LessonAccept { name } => {
|
||||||
if let Some(ref rt) = state.session_runtime {
|
if let Some(ref rt) = state.session_runtime {
|
||||||
let _ = crate::app::review::resolve_pending_lesson(
|
let _ = crate::app::review::resolve_pending_lesson(
|
||||||
@@ -459,7 +462,7 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return;
|
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 model = state.settings.model.clone();
|
||||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||||
.map(|p| p.api_base.clone());
|
.map(|p| p.api_base.clone());
|
||||||
@@ -482,12 +485,14 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
tools.extend(state.mcp_manager.as_tools());
|
tools.extend(state.mcp_manager.as_tools());
|
||||||
let tool_defs = crate::tool::tool_defs(&tools);
|
let tool_defs = crate::tool::tool_defs(&tools);
|
||||||
let ctx = state.tool_ctx();
|
let ctx = state.tool_ctx();
|
||||||
let mode = state.mode;
|
|
||||||
let edit_session_dir = state.session_dir.clone();
|
let edit_session_dir = state.session_dir.clone();
|
||||||
let session_id = state.session_id.clone();
|
let session_id = state.session_id.clone();
|
||||||
let turn_events = state.turn_events.clone();
|
let turn_events = state.turn_events.clone();
|
||||||
let in_flight_flag = state.turn_in_flight.clone();
|
let in_flight_flag = state.turn_in_flight.clone();
|
||||||
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.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;
|
*in_flight_flag.lock().unwrap() = true;
|
||||||
|
|
||||||
@@ -502,13 +507,14 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
tdefs: tool_defs,
|
tdefs: tool_defs,
|
||||||
tools,
|
tools,
|
||||||
ctx,
|
ctx,
|
||||||
mode,
|
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
edit_log_session_dir: edit_session_dir,
|
edit_log_session_dir: edit_session_dir,
|
||||||
session_id,
|
session_id,
|
||||||
db,
|
db,
|
||||||
temperature,
|
temperature,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
|
abort_flag,
|
||||||
};
|
};
|
||||||
let result = run_agent_turn(tc, &messages, &events_q);
|
let result = run_agent_turn(tc, &messages, &events_q);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -527,13 +533,14 @@ struct TurnCtx {
|
|||||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||||
ctx: crate::tool::ToolCtx,
|
ctx: crate::tool::ToolCtx,
|
||||||
mode: AgentMode,
|
|
||||||
workspace_roots: Vec<std::path::PathBuf>,
|
workspace_roots: Vec<std::path::PathBuf>,
|
||||||
edit_log_session_dir: std::path::PathBuf,
|
edit_log_session_dir: std::path::PathBuf,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
max_tokens: u32,
|
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) {
|
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 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,
|
&wire_msgs,
|
||||||
Some(tc.tdefs.clone()),
|
Some(tc.tdefs.clone()),
|
||||||
Some(tc.temperature),
|
Some(tc.temperature),
|
||||||
Some(tc.max_tokens),
|
Some(tc.max_tokens),
|
||||||
|event| match event {
|
|event| -> bool {
|
||||||
crate::app::runtime::stream::StreamEvent::Token(tok) => {
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
if let Ok(mut q) = events_q.lock() {
|
return false;
|
||||||
if !stream_started {
|
}
|
||||||
q.push_back(TurnEvent::StreamStart);
|
if let Ok(mut q) = events_q.lock() {
|
||||||
stream_started = true;
|
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, .. } => {
|
true
|
||||||
if let Ok(mut q) = events_q.lock() {
|
|
||||||
q.push_back(TurnEvent::Usage {
|
|
||||||
tokens_in: *prompt_tokens,
|
|
||||||
tokens_out: *completion_tokens,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
},
|
||||||
) {
|
);
|
||||||
Ok(result) => result,
|
|
||||||
Err(_stream_err) => {
|
let (response, final_usage) = match result {
|
||||||
// Provider doesn't support streaming — fall back to non-streaming
|
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(
|
let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming(
|
||||||
&wire_msgs, Some(tc.tdefs.clone()),
|
&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)
|
(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()
|
let has_tool_calls = response.tool_calls.is_some()
|
||||||
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
&& 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);
|
archive_message(&tc.db, &tc.session_id, &response);
|
||||||
msgs.push(response);
|
msgs.push(response);
|
||||||
for tool_call in tool_calls {
|
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 tool_name = tool_call.function.name.clone();
|
||||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
||||||
&tool_call.function.arguments,
|
&tool_call.function.arguments,
|
||||||
@@ -650,7 +673,7 @@ fn run_agent_turn(
|
|||||||
let verdict = crate::app::harness::Harness::gate_tool_call(
|
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||||
&tool_name,
|
&tool_name,
|
||||||
&args,
|
&args,
|
||||||
&tc.mode,
|
|
||||||
&ws_roots,
|
&ws_roots,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -671,7 +694,7 @@ fn run_agent_turn(
|
|||||||
},
|
},
|
||||||
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
||||||
Verdict::Escalate => (
|
Verdict::Escalate => (
|
||||||
"Tool requires approval. Switch to Auto mode or provide explicit approval."
|
"Tool requires approval. Provide explicit approval."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
message: "transcript cleared".to_string(),
|
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 } => {
|
Command::Login { provider } => {
|
||||||
vec![Action::StartOAuth { provider }]
|
vec![Action::StartOAuth { provider }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ impl SseParser {
|
|||||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
if let Some(event) = self.flush_event() {
|
events.extend(self.flush_event());
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||||
self.event_type = Some(ty.trim().to_string());
|
self.event_type = Some(ty.trim().to_string());
|
||||||
} else if let Some(data) = line.strip_prefix("data: ") {
|
} else if let Some(data) = line.strip_prefix("data: ") {
|
||||||
@@ -59,42 +57,61 @@ impl SseParser {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush_event(&mut self) -> Option<StreamEvent> {
|
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||||
let data = self.data_lines.join("\n");
|
let data = self.data_lines.join("\n");
|
||||||
self.data_lines.clear();
|
self.data_lines.clear();
|
||||||
let event_type = self.event_type.take().unwrap_or_default();
|
let event_type = self.event_type.take().unwrap_or_default();
|
||||||
if data.is_empty() || data == "[DONE]" {
|
if data.is_empty() || data == "[DONE]" {
|
||||||
if 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 let Some(usage) = value.get("usage") {
|
||||||
if !usage.is_null() {
|
if !usage.is_null() {
|
||||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
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 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())
|
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
||||||
.unwrap_or(prompt_tokens + completion_tokens);
|
.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() {
|
match event_type.as_str() {
|
||||||
"message.stop" => Some(StreamEvent::Done),
|
"message.stop" => vec![StreamEvent::Done],
|
||||||
"message.start" => None,
|
"message.start" => vec![],
|
||||||
"message.delta" | "" => {
|
"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() {
|
if let Some(choices) = delta.as_array() {
|
||||||
let choice = choices.first()?;
|
let choice = match choices.first() {
|
||||||
let delta = choice.get("delta")?;
|
Some(c) => c,
|
||||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
None => return vec![],
|
||||||
return Some(StreamEvent::Token(content.to_string()));
|
};
|
||||||
|
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 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 id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||||
let name = tc.get("function")
|
let name = tc.get("function")
|
||||||
@@ -106,27 +123,31 @@ impl SseParser {
|
|||||||
.and_then(|a| a.as_str())
|
.and_then(|a| a.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
return Some(StreamEvent::ToolCallDelta {
|
events.push(StreamEvent::ToolCallDelta {
|
||||||
index,
|
index,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
arguments_delta: args_delta,
|
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" {
|
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()) {
|
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) {
|
pub fn scroll_up(&mut self, amount: usize) {
|
||||||
if self.offset > 0 {
|
self.offset = self.offset.saturating_add(amount);
|
||||||
self.offset -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll_down(&mut self, total: usize) {
|
pub fn scroll_down(&mut self, amount: usize) {
|
||||||
let max_offset = total.saturating_sub(self.max_visible);
|
self.offset = self.offset.saturating_sub(amount);
|
||||||
if self.offset < max_offset {
|
|
||||||
self.offset += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_max_visible(&mut self, max: usize) {
|
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::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||||
use super::runtime::{SessionRuntime, TurnEvent};
|
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::mcp::manager::McpManager;
|
||||||
use crate::app::workflow::engine::WorkflowEngine;
|
use crate::app::workflow::engine::WorkflowEngine;
|
||||||
use crate::model::app_config::AppConfig;
|
use crate::model::app_config::AppConfig;
|
||||||
@@ -31,7 +31,7 @@ impl ChatMessageDisplay {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppStateRest {
|
pub struct AppStateRest {
|
||||||
pub mode: AgentMode,
|
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
pub app_config: AppConfig,
|
pub app_config: AppConfig,
|
||||||
pub workspace_roots: Vec<PathBuf>,
|
pub workspace_roots: Vec<PathBuf>,
|
||||||
@@ -50,6 +50,7 @@ pub struct AppStateRest {
|
|||||||
pub misc: MiscState,
|
pub misc: MiscState,
|
||||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
pub turn_in_flight: Arc<Mutex<bool>>,
|
pub turn_in_flight: Arc<Mutex<bool>>,
|
||||||
|
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub workflow_engine: WorkflowEngine,
|
pub workflow_engine: WorkflowEngine,
|
||||||
pub mcp_manager: McpManager,
|
pub mcp_manager: McpManager,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
@@ -68,7 +69,7 @@ impl AppStateRest {
|
|||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
AppStateRest {
|
AppStateRest {
|
||||||
mode: AgentMode::Auto,
|
|
||||||
settings,
|
settings,
|
||||||
app_config,
|
app_config,
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
@@ -79,6 +80,7 @@ impl AppStateRest {
|
|||||||
worktrees_dir,
|
worktrees_dir,
|
||||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
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)),
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||||
edit_log: EditLog::new(&session_dir),
|
edit_log: EditLog::new(&session_dir),
|
||||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
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)
|
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) {
|
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||||
self.transcript_cache.messages.push(msg);
|
self.transcript_cache.messages.push(msg);
|
||||||
|
|||||||
+1
-34
@@ -1,36 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ToastKind {
|
pub enum ToastKind {
|
||||||
@@ -73,8 +42,7 @@ pub enum Overlay {
|
|||||||
Bash,
|
Bash,
|
||||||
QuitConfirm,
|
QuitConfirm,
|
||||||
Workflow,
|
Workflow,
|
||||||
Onboard,
|
|
||||||
OnboardProvider,
|
|
||||||
KeyInput,
|
KeyInput,
|
||||||
Editor,
|
Editor,
|
||||||
Effort,
|
Effort,
|
||||||
@@ -87,7 +55,6 @@ pub enum Overlay {
|
|||||||
Loading,
|
Loading,
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
ClearConfirm,
|
ClearConfirm,
|
||||||
LoginPicker,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
|
|||||||
+82
-41
@@ -1,20 +1,50 @@
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use crate::dto::chat::message::ChatMessage;
|
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::context::SubagentContext;
|
||||||
use super::event::SubagentEvent;
|
use super::event::SubagentEvent;
|
||||||
|
|
||||||
pub const MAX_AGENT_STEPS: usize = 25;
|
pub const MAX_AGENT_STEPS: usize = 25;
|
||||||
|
|
||||||
fn tool_call_from_response(response: &str) -> Vec<String> {
|
/// Maps a subagent's allowed tool names to concrete Tool trait objects and
|
||||||
let mut calls = Vec::new();
|
/// OpenAI-style tool definitions. When `allowed_tools` is empty every tool is
|
||||||
for line in response.lines() {
|
/// available; otherwise only explicitly allowed ones are included.
|
||||||
let trimmed = line.trim();
|
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||||
if let Some(tool_call) = trimmed.strip_prefix("Tool: ") {
|
let all = all_tools();
|
||||||
calls.push(tool_call.to_string());
|
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> {
|
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)
|
.origin(crate::app::state::types::Origin::SubAgent)
|
||||||
.build();
|
.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);
|
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||||
for step in 0..max_steps {
|
for step in 0..max_steps {
|
||||||
let api_key = std::env::var("API_KEY").unwrap_or_default();
|
let (api_key, model, base_url) = resolve_provider_config();
|
||||||
let model = std::env::var("MODEL").unwrap_or_default();
|
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
||||||
|
|
||||||
let client = crate::service::provider::LlmClient::new(api_key, model, None);
|
// Use the structured tool-calling API so the LLM can request tools with
|
||||||
let response = match client.chat(&messages) {
|
// proper arguments, exactly like the main agent does.
|
||||||
Ok(r) => r,
|
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
|
||||||
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||||
_step: step,
|
_step: step,
|
||||||
@@ -44,31 +79,30 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
let has_tool_calls = response.tool_calls.is_some()
|
||||||
_tool: "api".to_string(),
|
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||||
_args: serde_json::json!({"response": response}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let tool_calls = tool_call_from_response(&response);
|
let content = response.content.clone().unwrap_or_default();
|
||||||
if tool_calls.is_empty() {
|
|
||||||
output.push_str(&response);
|
if has_tool_calls {
|
||||||
output.push('\n');
|
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
||||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
// Push the assistant message with tool_calls into the conversation
|
||||||
_step: step,
|
messages.push(response);
|
||||||
_output: response.clone(),
|
|
||||||
});
|
for tool_call in &tool_calls {
|
||||||
if !response.contains("Tool:") {
|
let tool_name = &tool_call.function.name;
|
||||||
break;
|
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let tools = all_tools();
|
|
||||||
for tool_name in &tool_calls {
|
|
||||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
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 {
|
if !generally_allowed {
|
||||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
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 {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
_tool: tool_name.clone(),
|
_tool: tool_name.clone(),
|
||||||
_output: msg,
|
_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 {
|
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||||
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
|
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 {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
_tool: tool_name.clone(),
|
_tool: tool_name.clone(),
|
||||||
_output: msg,
|
_output: msg,
|
||||||
@@ -86,13 +120,14 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match tools.iter().find(|t| t.name() == *tool_name) {
|
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||||
Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})),
|
Some(tool) => tool.run(&tool_ctx, &args),
|
||||||
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output_text) => {
|
Ok(output_text) => {
|
||||||
|
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
||||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
_tool: tool_name.clone(),
|
_tool: tool_name.clone(),
|
||||||
_output: output_text,
|
_output: output_text,
|
||||||
@@ -100,6 +135,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("tool '{}' failed: {}", tool_name, 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 {
|
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||||
_tool: tool_name.clone(),
|
_tool: tool_name.clone(),
|
||||||
_output: msg,
|
_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 {
|
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||||
_step: step,
|
_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() });
|
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 {
|
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||||
self.max_steps = Some(steps);
|
self.max_steps = Some(steps);
|
||||||
self
|
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 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()),
|
||||||
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||||
"/lesson" => Command::LessonList,
|
"/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() },
|
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
||||||
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
||||||
"/edit" => Command::Edit(".".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 {
|
} else if state.misc.overlay == Overlay::Effort {
|
||||||
mode::effort::cycle_effort(state);
|
mode::effort::cycle_effort(state);
|
||||||
Vec::new()
|
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 {
|
} else if state.misc.overlay == Overlay::Rewind {
|
||||||
let n = mode::rewind::rewind_count(state);
|
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 };
|
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.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
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 {
|
} else {
|
||||||
vec![Action::ScrollUp]
|
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 {
|
} else if state.misc.overlay == Overlay::Effort {
|
||||||
mode::effort::cycle_effort(state);
|
mode::effort::cycle_effort(state);
|
||||||
Vec::new()
|
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 {
|
} else if state.misc.overlay == Overlay::Rewind {
|
||||||
let n = mode::rewind::rewind_count(state);
|
let n = mode::rewind::rewind_count(state);
|
||||||
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
|
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.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
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 {
|
} else {
|
||||||
vec![Action::ScrollDown]
|
vec![Action::ScrollDown]
|
||||||
}
|
}
|
||||||
@@ -174,7 +158,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
vec![Action::ScrollDown]
|
vec![Action::ScrollDown]
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
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.input.close_autocomplete();
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -232,7 +218,11 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
Overlay::KeyInput => {
|
Overlay::KeyInput => {
|
||||||
let text = state.input.buffer.clone();
|
let text = state.input.buffer.clone();
|
||||||
mode::key_input::handle_key_text(state, text.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();
|
let _ = state.settings.save();
|
||||||
state.input.buffer.clear();
|
state.input.buffer.clear();
|
||||||
state.input.cursor = 0;
|
state.input.cursor = 0;
|
||||||
@@ -244,26 +234,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
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 => {
|
Overlay::Mcp => {
|
||||||
mode::mcp::connect_mcp(state, "");
|
mode::mcp::connect_mcp(state, "");
|
||||||
Vec::new()
|
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());
|
let model = cfg.default_model.clone().unwrap_or_else(|| "claude-opus-4-8".to_string());
|
||||||
state.settings.provider = provider.clone();
|
state.settings.provider = provider.clone();
|
||||||
state.settings.model = model.clone();
|
state.settings.model = model.clone();
|
||||||
// Use the selected provider's default API key
|
if let Some(ref key) = cfg.default_api_key {
|
||||||
state.settings.api_key = if let Some(ref key) = cfg.default_api_key {
|
state.settings.api_keys.insert(provider.clone(), key.clone());
|
||||||
Some(key.clone())
|
} else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) {
|
||||||
} else {
|
state.settings.api_keys.insert(provider.clone(), env_key);
|
||||||
state.settings.api_key.clone().or_else(|| cfg.api_key_env.as_ref()
|
}
|
||||||
.and_then(|env| std::env::var(env).ok()))
|
|
||||||
};
|
|
||||||
let _ = state.settings.save();
|
let _ = state.settings.save();
|
||||||
state.push_toast(crate::app::state::types::Toast::new(
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
crate::app::state::types::ToastKind::Success,
|
crate::app::state::types::ToastKind::Success,
|
||||||
@@ -307,11 +276,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
Overlay::LoginPicker => {
|
|
||||||
state.misc.overlay = Overlay::OnboardProvider;
|
|
||||||
state.dirty = true;
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub struct ToastEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StatePayload {
|
pub struct StatePayload {
|
||||||
pub mode: String,
|
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub messages: Vec<MessageEntry>,
|
pub messages: Vec<MessageEntry>,
|
||||||
pub edit_count: u32,
|
pub edit_count: u32,
|
||||||
|
|||||||
+18
-22
@@ -65,15 +65,14 @@ fn run_single_process() -> Result<()> {
|
|||||||
store.memory_dir,
|
store.memory_dir,
|
||||||
);
|
);
|
||||||
state.sessions = model::session::Session::list(&store.base_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()?;
|
let _rt = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
@@ -81,7 +80,7 @@ fn run_single_process() -> Result<()> {
|
|||||||
let run_result = run_loop(&mut state, &mut terminal);
|
let run_result = run_loop(&mut state, &mut terminal);
|
||||||
|
|
||||||
let mut restore_stdout = io::stdout();
|
let mut restore_stdout = io::stdout();
|
||||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
let _ = execute!(restore_stdout, LeaveAlternateScreen, crossterm::event::DisableMouseCapture);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
|
|
||||||
if let Err(e) = run_result {
|
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 {
|
let frame = DaemonFrame::StateUpdate(Box::new(StatePayload {
|
||||||
mode: state.mode.name().to_string(),
|
|
||||||
session_id: state.session_id.clone(),
|
session_id: state.session_id.clone(),
|
||||||
messages,
|
messages,
|
||||||
edit_count: state.edit_log.len() as u32,
|
edit_count: state.edit_log.len() as u32,
|
||||||
@@ -184,15 +182,7 @@ fn apply_client_update(
|
|||||||
state: &mut app::state::rest::AppStateRest,
|
state: &mut app::state::rest::AppStateRest,
|
||||||
payload: ipc::protocol::StatePayload,
|
payload: ipc::protocol::StatePayload,
|
||||||
) {
|
) {
|
||||||
use app::state::types::{AgentMode, Overlay, Toast, ToastKind};
|
use app::state::types::{Overlay, Toast, ToastKind};
|
||||||
|
|
||||||
state.mode = match payload.mode.as_str() {
|
|
||||||
"Auto" => AgentMode::Auto,
|
|
||||||
"Normal" => AgentMode::Normal,
|
|
||||||
"Plan" => AgentMode::Plan,
|
|
||||||
"Yolo" => AgentMode::Yolo,
|
|
||||||
_ => state.mode,
|
|
||||||
};
|
|
||||||
state.session_id = payload.session_id;
|
state.session_id = payload.session_id;
|
||||||
state.dirty = payload.dirty;
|
state.dirty = payload.dirty;
|
||||||
|
|
||||||
@@ -218,8 +208,7 @@ fn apply_client_update(
|
|||||||
Some("Bash") => Overlay::Bash,
|
Some("Bash") => Overlay::Bash,
|
||||||
Some("QuitConfirm") => Overlay::QuitConfirm,
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
||||||
Some("Workflow") => Overlay::Workflow,
|
Some("Workflow") => Overlay::Workflow,
|
||||||
Some("Onboard") => Overlay::Onboard,
|
|
||||||
Some("OnboardProvider") => Overlay::OnboardProvider,
|
|
||||||
Some("KeyInput") => Overlay::KeyInput,
|
Some("KeyInput") => Overlay::KeyInput,
|
||||||
Some("Editor") => Overlay::Editor,
|
Some("Editor") => Overlay::Editor,
|
||||||
Some("Effort") => Overlay::Effort,
|
Some("Effort") => Overlay::Effort,
|
||||||
@@ -232,7 +221,7 @@ fn apply_client_update(
|
|||||||
Some("Loading") => Overlay::Loading,
|
Some("Loading") => Overlay::Loading,
|
||||||
Some("ModelSelector") => Overlay::ModelSelector,
|
Some("ModelSelector") => Overlay::ModelSelector,
|
||||||
Some("ClearConfirm") => Overlay::ClearConfirm,
|
Some("ClearConfirm") => Overlay::ClearConfirm,
|
||||||
Some("LoginPicker") => Overlay::LoginPicker,
|
|
||||||
_ => Overlay::None,
|
_ => Overlay::None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,7 +359,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
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 _ = disable_raw_mode();
|
||||||
|
|
||||||
let _ = client_state.settings.save();
|
let _ = client_state.settings.save();
|
||||||
@@ -472,7 +461,7 @@ fn run_loop(
|
|||||||
let _ = terminal.clear();
|
let _ = terminal.clear();
|
||||||
|
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::event::DisableMouseCapture);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -482,7 +471,7 @@ fn run_loop_inner(
|
|||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use crossterm::event::{Event, KeyEventKind};
|
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
|
||||||
use controller::input::handle_key;
|
use controller::input::handle_key;
|
||||||
use app::runtime::actions::{Action, apply_action};
|
use app::runtime::actions::{Action, apply_action};
|
||||||
|
|
||||||
@@ -509,6 +498,13 @@ fn run_loop_inner(
|
|||||||
Event::Resize(w, h) => {
|
Event::Resize(w, h) => {
|
||||||
apply_action(state, Action::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_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
||||||
api_key_env: None,
|
api_key_env: None,
|
||||||
default_model: Some("claude-opus-4-8".to_string()),
|
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();
|
let mut model_roles = HashMap::new();
|
||||||
model_roles.insert("default".to_string(), ModelRole {
|
model_roles.insert("default".to_string(), ModelRole {
|
||||||
|
|||||||
+18
-4
@@ -20,10 +20,24 @@ pub struct EditLog {
|
|||||||
|
|
||||||
impl EditLog {
|
impl EditLog {
|
||||||
pub fn new(session_dir: &std::path::Path) -> Self {
|
pub fn new(session_dir: &std::path::Path) -> Self {
|
||||||
EditLog {
|
let path = session_dir.join("edits.jsonl");
|
||||||
entries: Vec::new(),
|
let entries = Self::load_from_disk(&path);
|
||||||
path: session_dir.join("edits.jsonl"),
|
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<()> {
|
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub struct Settings {
|
|||||||
pub internet_mode: InternetMode,
|
pub internet_mode: InternetMode,
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub api_key: Option<String>,
|
pub api_keys: std::collections::HashMap<String, String>,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: u32,
|
||||||
pub temperature: f32,
|
pub temperature: f32,
|
||||||
pub review_enabled: bool,
|
pub review_enabled: bool,
|
||||||
@@ -47,7 +47,7 @@ impl Default for Settings {
|
|||||||
internet_mode: InternetMode::Off,
|
internet_mode: InternetMode::Off,
|
||||||
provider: "zen".to_string(),
|
provider: "zen".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
api_key: None,
|
api_keys: std::collections::HashMap::new(),
|
||||||
max_tokens: 8192,
|
max_tokens: 8192,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
review_enabled: true,
|
review_enabled: true,
|
||||||
|
|||||||
+13
-7
@@ -121,7 +121,9 @@ impl LlmClient {
|
|||||||
match result {
|
match result {
|
||||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||||
Err(e) => {
|
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);
|
return Err(e);
|
||||||
}
|
}
|
||||||
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
||||||
@@ -143,7 +145,7 @@ impl LlmClient {
|
|||||||
tools: Option<Vec<ToolDef>>,
|
tools: Option<Vec<ToolDef>>,
|
||||||
temperature: Option<f32>,
|
temperature: Option<f32>,
|
||||||
max_tokens: Option<u32>,
|
max_tokens: Option<u32>,
|
||||||
mut on_event: impl FnMut(&StreamEvent),
|
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
||||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
let req = ChatRequest {
|
let req = ChatRequest {
|
||||||
model: self.model.clone(),
|
model: self.model.clone(),
|
||||||
@@ -164,14 +166,16 @@ impl LlmClient {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
let mut wrapped = |event: &StreamEvent| {
|
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||||
started = true;
|
started = true;
|
||||||
on_event(event);
|
on_event(event)
|
||||||
};
|
};
|
||||||
match self.try_stream_once(&req, &url, &mut wrapped) {
|
match self.try_stream_once(&req, &url, &mut wrapped) {
|
||||||
Ok(result) => return Ok(result),
|
Ok(result) => return Ok(result),
|
||||||
Err(e) => {
|
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);
|
return Err(e);
|
||||||
}
|
}
|
||||||
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries);
|
||||||
@@ -185,7 +189,7 @@ impl LlmClient {
|
|||||||
&self,
|
&self,
|
||||||
req: &ChatRequest,
|
req: &ChatRequest,
|
||||||
url: &str,
|
url: &str,
|
||||||
on_event: &mut dyn FnMut(&StreamEvent),
|
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
|
||||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
@@ -237,7 +241,9 @@ impl LlmClient {
|
|||||||
byte_buf.drain(..valid_len);
|
byte_buf.drain(..valid_len);
|
||||||
|
|
||||||
for event in parser.feed(&text) {
|
for event in parser.feed(&text) {
|
||||||
on_event(&event);
|
if !on_event(&event) {
|
||||||
|
anyhow::bail!("aborted");
|
||||||
|
}
|
||||||
match &event {
|
match &event {
|
||||||
StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
|
||||||
usage = Some((*prompt_tokens, *completion_tokens));
|
usage = Some((*prompt_tokens, *completion_tokens));
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ impl Tool for DirCacheUpdate {
|
|||||||
let count = entries.len();
|
let count = entries.len();
|
||||||
let dc = ctx.dir_cache.clone();
|
let dc = ctx.dir_cache.clone();
|
||||||
|
|
||||||
let rt = tokio::runtime::Handle::try_current()
|
// Create a one-shot runtime so this tool works from any thread (the
|
||||||
.map_err(|e| anyhow!("no tokio runtime available: {}", e))?;
|
// agent turn runs on a std::thread that has no tokio context).
|
||||||
rt.block_on(async move {
|
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;
|
let cache = dc.write().await;
|
||||||
cache.set(entries).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) {
|
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
match state.mode {
|
chat::draw_chat(frame, area, state);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) {
|
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 => {
|
crate::app::state::types::Overlay::Workflow => {
|
||||||
workflow::draw_workflow_panel(frame, overlay_area, state);
|
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 => {
|
crate::app::state::types::Overlay::KeyInput => {
|
||||||
let block = block.title(" Input ");
|
let block = block.title(" Input ");
|
||||||
let input_text = &state.input.buffer;
|
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),
|
format!("Messages: {}", msg_count),
|
||||||
Style::default().fg(Theme::INFO),
|
Style::default().fg(Theme::INFO),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("Mode: {}", state.mode.name()),
|
|
||||||
Style::default().fg(Theme::TEXT),
|
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!("Overlay: {:?}", state.misc.overlay),
|
format!("Overlay: {:?}", state.misc.overlay),
|
||||||
Style::default().fg(Theme::DIM),
|
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);
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
frame.render_widget(paragraph, overlay_area);
|
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;
|
use super::theme::Theme;
|
||||||
|
|
||||||
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||||
let left_text = Span::styled(
|
// Connection status — reflects actual agent readiness:
|
||||||
" [zesdex] ",
|
// 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()
|
Style::default()
|
||||||
.fg(Theme::TEXT)
|
.fg(if agent_status == "NOAPI" { Theme::DIM } else { Theme::BG })
|
||||||
|
.bg(conn_color)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
);
|
);
|
||||||
|
|
||||||
let agent_status = if state.turn_in_flight() {
|
// Left chunk: [zesdex] STATUS
|
||||||
"PROCESSING"
|
let mut spans = vec![
|
||||||
} else if state.misc.api_connected {
|
Span::styled(" [zesdex] ", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)),
|
||||||
"READY"
|
status,
|
||||||
} 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),
|
|
||||||
);
|
|
||||||
|
|
||||||
let right_text = Span::styled(
|
// Right chunk: provider · model
|
||||||
format!(" | {} ", state.settings.model),
|
spans.push(Span::styled(
|
||||||
|
format!(" {} · {} ", state.settings.provider, state.settings.model),
|
||||||
Style::default().fg(Theme::DIM),
|
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()
|
let block = Block::default()
|
||||||
.style(
|
.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 {
|
let phase_status = if state.turn_in_flight() {
|
||||||
crate::app::state::types::AgentMode::Auto => "Auto-running",
|
"Auto-running"
|
||||||
crate::app::state::types::AgentMode::Normal => "Awaiting input",
|
} else {
|
||||||
crate::app::state::types::AgentMode::Plan => "Planning",
|
"Awaiting input"
|
||||||
crate::app::state::types::AgentMode::Yolo => "Full autonomy",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
items.push(ListItem::new(Line::from(Span::styled(
|
items.push(ListItem::new(Line::from(Span::styled(
|
||||||
|
|||||||
Reference in New Issue
Block a user