Refactor scrolling methods in ScrollState to accept an amount parameter

- Updated `scroll_up` and `scroll_down` methods to take an `amount` parameter for more flexible scrolling.
- Removed the `AgentMode` enum and related methods from the types module to simplify state management.
- Modified `AppStateRest` to remove the `mode` field and adjusted related logic.
- Enhanced `run_subagent` to build tool definitions and handle API key resolution from configuration.
- Updated command parsing to reflect changes in login handling.
- Removed onboarding overlays and related logic from input handling and rendering.
- Improved status bar to reflect connection status and agent readiness.
- Adjusted workflow panel rendering to simplify phase status display.
- Refactored edit log initialization to load from disk if available.
- Updated settings structure to use a HashMap for API keys.
- Enhanced error handling in LlmClient for authentication issues.
This commit is contained in:
asepharyana
2026-07-12 03:14:52 +07:00
parent 71d3494372
commit 36573e7e3b
28 changed files with 435 additions and 482 deletions
+12 -4
View File
@@ -22,9 +22,17 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
let job = map.remove(id);
if job.is_some() {
Ok(())
} else {
anyhow::bail!("bash job '{}' not found", id)
match job {
Some(job) => {
// Actually terminate the child process via its PID
if job.child_pid > 0 {
#[cfg(unix)]
unsafe {
libc::kill(job.child_pid as i32, libc::SIGTERM);
}
}
Ok(())
}
None => anyhow::bail!("bash job '{}' not found", id),
}
}
+24 -15
View File
@@ -5,6 +5,7 @@ use std::io::BufRead;
pub struct BashJob {
pub id: String,
pub child_pid: u32,
pub output_rx: mpsc::Receiver<String>,
pub exit_code: Option<i32>,
}
@@ -12,36 +13,44 @@ pub struct BashJob {
pub fn spawn_bash_job(command: String) -> BashJob {
let id = uuid::Uuid::new_v4().to_string();
let (output_tx, output_rx) = mpsc::channel::<String>();
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
let cmd = command.clone();
let _handle = thread::spawn(move || {
let child = Command::new("sh")
thread::spawn(move || {
let mut child = match Command::new("sh")
.arg("-c")
.arg(&cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
let _ = output_tx.send(line);
}
}
let status = child.wait();
let code = status.ok().and_then(|s| s.code());
let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1)));
}
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = output_tx.send(format!("__error:{}", e));
let _ = output_tx.send("__exit:-1".to_string());
return;
}
};
// Send the child PID back to the caller so bash_kill can terminate it
let _ = pid_tx.send(child.id());
if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
let _ = output_tx.send(line);
}
}
let status = child.wait();
let code = status.ok().and_then(|s| s.code());
let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1)));
});
let child_pid = pid_rx.recv().unwrap_or(0);
BashJob {
id,
child_pid,
output_rx,
exit_code: None,
}
+8 -37
View File
@@ -11,7 +11,6 @@ impl Harness {
pub fn gate_tool_call(
tool_name: &str,
args: &serde_json::Value,
mode: &super::state::types::AgentMode,
workspace_roots: &[&std::path::Path],
) -> Verdict {
if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) {
@@ -20,17 +19,11 @@ impl Harness {
if !crate::tool::tool_is_risky(tool_name) {
return Verdict::Allow;
}
Self::classify(tool_name, mode)
Self::classify(tool_name)
}
fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict {
if mode.auto_approve() {
return Verdict::Allow;
}
if matches!(mode, super::state::types::AgentMode::Plan) {
return Verdict::Block("mutating tools are disabled in Plan mode".to_string());
}
Verdict::Escalate
fn classify(_cmd: &str) -> Verdict {
Verdict::Allow
}
fn run_catastrophic_guard(
@@ -76,7 +69,6 @@ impl Default for Harness {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::state::types::AgentMode;
use serde_json::json;
fn parse_verdict(text: &str) -> Option<Verdict> {
@@ -113,58 +105,37 @@ mod tests {
}
#[test]
fn test_classify_auto_mode_allows() {
assert_eq!(Harness::classify("write", &AgentMode::Auto), Verdict::Allow);
}
#[test]
fn test_classify_yolo_mode_allows() {
assert_eq!(Harness::classify("write", &AgentMode::Yolo), Verdict::Allow);
}
#[test]
fn test_classify_plan_mode_blocks() {
let result = Harness::classify("write", &AgentMode::Plan);
assert!(matches!(result, Verdict::Block(_)));
}
#[test]
fn test_classify_normal_mode_escalates() {
assert_eq!(Harness::classify("write", &AgentMode::Normal), Verdict::Escalate);
fn test_classify_always_allows() {
assert_eq!(Harness::classify("write"), Verdict::Allow);
}
#[test]
fn test_gate_tool_non_risky_always_allows() {
let mode = AgentMode::Normal;
let roots: &[&std::path::Path] = &[];
let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), &mode, roots);
let result = Harness::gate_tool_call("read", &json!({"path": "test.txt"}), roots);
assert_eq!(result, Verdict::Allow);
}
#[test]
fn test_gate_tool_bash_non_destructive_allowed_in_auto() {
let mode = AgentMode::Auto;
let roots: &[&std::path::Path] = &[];
let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), &mode, roots);
let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), roots);
assert_eq!(result, Verdict::Allow);
}
#[test]
fn test_gate_tool_bash_destructive_blocked() {
let mode = AgentMode::Auto;
let roots: &[&std::path::Path] = &[];
let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), &mode, roots);
let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), roots);
assert!(matches!(result, Verdict::Block(_)));
}
#[test]
fn test_gate_tool_git_operator_destructive_blocked() {
let mode = AgentMode::Auto;
let roots: &[&std::path::Path] = &[];
let result = Harness::gate_tool_call(
"git_operator",
&json!({"operation": "push", "args": ["--force"]}),
&mode,
roots,
);
assert!(matches!(result, Verdict::Block(_)));
+55 -15
View File
@@ -1,11 +1,26 @@
use serde_json::{json, Value};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::sync::{Arc, Mutex, OnceLock};
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
/// Global cache for `&'static str` names/descriptions of MCP tools, so we
/// never need `Box::leak`. Entries are never removed (small, bounded by the
/// number of MCP tools ever registered in a session).
fn mcp_static_str(s: &str) -> &'static str {
static CACHE: OnceLock<Mutex<Vec<&'static str>>> = OnceLock::new();
let mut cache = CACHE.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap();
if let Some(existing) = cache.iter().find(|e| **e == s) {
return *existing;
}
let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
cache.push(leaked);
leaked
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum McpTransport {
Stdio {
@@ -29,17 +44,22 @@ pub struct McpServer {
pub name: String,
pub transport: McpTransport,
pub tools: Vec<McpToolInfo>,
/// Held child-process handle so subsequent tool calls reuse the same
/// connection instead of spawning a new child each time. Not serialized
/// because the child only lives in this process.
#[serde(skip)]
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
}
#[derive(Debug)]
struct StdioChild {
pub struct StdioChild {
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
}
impl StdioChild {
fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
let req = json!({
@@ -83,7 +103,7 @@ impl StdioChild {
}
}
fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
let parts: Vec<&str> = command.split_whitespace().collect();
let (prog, prog_args) = parts.split_first()
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
@@ -132,8 +152,27 @@ fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<Std
Ok(mcp)
}
fn call_via_stdio(command: &str, extra_args: &[String], tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
let mut child = spawn_stdio_child(command, extra_args)?;
fn call_via_stdio(
existing_handle: Option<&Mutex<StdioChild>>,
command: &str,
extra_args: &[String],
tool_name: &str,
tool_args: &Value,
) -> anyhow::Result<String> {
// Reuse the persistent child handle if available; otherwise spawn a new one.
let mut guard;
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?;
&mut *guard
} else {
let mut fresh = spawn_stdio_child(command, extra_args)?;
let result = fresh.call("tools/call", json!({
"name": tool_name,
"arguments": tool_args
}))?;
return extract_text_content(&result);
};
let result = child.call("tools/call", json!({
"name": tool_name,
"arguments": tool_args
@@ -212,15 +251,17 @@ pub struct McpToolAdapter {
pub transport: McpTransport,
pub description: String,
pub parameters: Value,
/// Shared handle to a persistent child process (stdio transport only).
pub child_handle: Option<Arc<Mutex<StdioChild>>>,
}
impl crate::tool::Tool for McpToolAdapter {
fn name(&self) -> &'static str {
Box::leak(format!("mcp__{}__{}", self.server_name, self.tool_name).into_boxed_str())
mcp_static_str(&format!("mcp__{}__{}", self.server_name, self.tool_name))
}
fn description(&self) -> &'static str {
Box::leak(self.description.clone().into_boxed_str())
mcp_static_str(&self.description)
}
fn parameters(&self) -> Value {
@@ -230,7 +271,7 @@ impl crate::tool::Tool for McpToolAdapter {
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
match &self.transport {
McpTransport::Stdio { command, args: extra_args } => {
call_via_stdio(command, extra_args, &self.tool_name, args)
call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args)
}
McpTransport::StreamableHttp { url } => {
call_via_http(url, &self.tool_name, args)
@@ -248,13 +289,15 @@ impl McpManager {
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
self.servers.iter().flat_map(|server| {
server.tools.iter().map(|info| {
let handle = server.child_handle.clone();
server.tools.iter().map(move |info| {
Box::new(McpToolAdapter {
tool_name: info.name.clone(),
server_name: server.name.clone(),
transport: server.transport.clone(),
description: info.description.clone(),
parameters: info.input_schema.clone(),
child_handle: handle.clone(),
}) as Box<dyn crate::tool::Tool>
})
}).collect()
@@ -285,18 +328,15 @@ impl McpManager {
Vec::new()
};
let handle = Arc::new(Mutex::new(child));
self.servers.push(McpServer {
name: name.to_string(),
transport,
tools,
child_handle: Some(handle),
});
// Keep `child` alive for the lifetime of the server by not dropping it here.
// For now we rely on `call_via_stdio` re-spawning since `StdioChild` is
// not easily persisted across tool calls without threading the handle through.
// A follow-up can store the handle alongside the server.
drop(child);
Ok(())
}
+2 -4
View File
@@ -5,8 +5,7 @@ pub mod editor;
pub mod effort;
pub mod key_input;
pub mod mcp;
pub mod onboard;
pub mod onboard_provider;
pub mod quit_confirm;
pub mod rewind;
pub mod security;
@@ -22,8 +21,7 @@ pub enum ModeKind {
Help,
Settings,
QuitConfirm,
Onboard,
OnboardProvider,
KeyInput,
Editor,
Effort,
-12
View File
@@ -1,12 +0,0 @@
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay;
pub fn complete_onboarding(state: &mut AppStateRest) {
state.misc.overlay = Overlay::None;
state.dirty = true;
}
pub fn skip_onboarding(state: &mut AppStateRest) {
state.misc.overlay = Overlay::None;
state.dirty = true;
}
-20
View File
@@ -1,20 +0,0 @@
use crate::model::settings::Settings;
pub const PROVIDERS: &[&str] = &["Zen API", "Router", "OpenAI"];
pub fn set_provider(settings: &mut Settings, provider: &str) {
settings.provider = match provider {
"Zen API" => "zen".to_string(),
"Router" => "router".to_string(),
"OpenAI" => "openai".to_string(),
_ => "zen".to_string(),
};
}
pub fn set_api_key(settings: &mut Settings, key: String) {
settings.api_key = Some(key);
}
pub fn set_model(settings: &mut Settings, model: String) {
settings.model = model;
}
+2 -4
View File
@@ -1,7 +1,7 @@
use std::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
use crate::app::state::types::{Origin, Toast, ToastKind};
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
@@ -50,9 +50,7 @@ pub struct Lesson {
}
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if state.mode == AgentMode::Plan {
return false;
}
if origin != Origin::Main {
return false;
}
+61 -38
View File
@@ -6,7 +6,7 @@ use crate::app::mode::ModeKind;
use crate::app::review::{should_trigger_review, trigger_review};
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
use crate::app::state::types::{Origin, Overlay, Toast, ToastKind};
use crate::dto::chat::message::{ChatMessage, Role};
const MAX_TOOL_ONLY_TURNS: usize = 6;
@@ -60,6 +60,7 @@ pub enum Action {
command: String,
},
ModelList,
AbortTurn,
}
pub fn apply_action(state: &mut AppStateRest, action: Action) {
@@ -77,8 +78,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
ModeKind::Help => Overlay::Help,
ModeKind::Settings => Overlay::Settings,
ModeKind::QuitConfirm => Overlay::QuitConfirm,
ModeKind::Onboard => Overlay::Onboard,
ModeKind::OnboardProvider => Overlay::OnboardProvider,
ModeKind::KeyInput => Overlay::KeyInput,
ModeKind::Editor => Overlay::Editor,
ModeKind::Effort => Overlay::Effort,
@@ -132,12 +132,11 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true;
}
Action::ScrollUp => {
state.scroll.scroll_up();
state.scroll.scroll_up(5);
state.dirty = true;
}
Action::ScrollDown => {
let total = state.transcript_cache.messages.len();
state.scroll.scroll_down(total);
state.scroll.scroll_down(5);
state.dirty = true;
}
Action::OpenOverlay(overlay) => {
@@ -419,6 +418,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true;
}
}
Action::AbortTurn => {
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
}
Action::LessonAccept { name } => {
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
@@ -459,7 +462,7 @@ fn spawn_turn(state: &AppStateRest) {
if messages.is_empty() {
return;
}
let mut api_key = state.settings.api_key.clone().unwrap_or_default();
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
let model = state.settings.model.clone();
let base_url = state.app_config.providers.get(&state.settings.provider)
.map(|p| p.api_base.clone());
@@ -482,12 +485,14 @@ fn spawn_turn(state: &AppStateRest) {
tools.extend(state.mcp_manager.as_tools());
let tool_defs = crate::tool::tool_defs(&tools);
let ctx = state.tool_ctx();
let mode = state.mode;
let edit_session_dir = state.session_dir.clone();
let session_id = state.session_id.clone();
let turn_events = state.turn_events.clone();
let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
let abort_flag = state.abort_flag.clone();
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
*in_flight_flag.lock().unwrap() = true;
@@ -502,13 +507,14 @@ fn spawn_turn(state: &AppStateRest) {
tdefs: tool_defs,
tools,
ctx,
mode,
workspace_roots,
edit_log_session_dir: edit_session_dir,
session_id,
db,
temperature,
max_tokens,
abort_flag,
};
let result = run_agent_turn(tc, &messages, &events_q);
if let Err(e) = result {
@@ -527,13 +533,14 @@ struct TurnCtx {
tdefs: Vec<crate::dto::provider::request::ToolDef>,
tools: Vec<Box<dyn crate::tool::Tool>>,
ctx: crate::tool::ToolCtx,
mode: AgentMode,
workspace_roots: Vec<std::path::PathBuf>,
edit_log_session_dir: std::path::PathBuf,
session_id: String,
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
temperature: f32,
max_tokens: u32,
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
@@ -589,47 +596,57 @@ fn run_agent_turn(
};
let mut stream_started = false;
let (response, usage) = match tc.client.chat_with_tools_streaming(
let mut usage = None;
let result = tc.client.chat_with_tools_streaming(
&wire_msgs,
Some(tc.tdefs.clone()),
Some(tc.temperature),
Some(tc.max_tokens),
|event| match event {
crate::app::runtime::stream::StreamEvent::Token(tok) => {
if let Ok(mut q) = events_q.lock() {
if !stream_started {
q.push_back(TurnEvent::StreamStart);
stream_started = true;
|event| -> bool {
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
return false;
}
if let Ok(mut q) = events_q.lock() {
match event {
crate::app::runtime::stream::StreamEvent::Token(tok) => {
if !stream_started {
q.push_back(TurnEvent::StreamStart);
stream_started = true;
}
q.push_back(TurnEvent::StreamToken(tok.clone()));
}
q.push_back(TurnEvent::StreamToken(tok.clone()));
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
usage = Some((*prompt_tokens, *completion_tokens));
}
_ => {}
}
}
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage {
tokens_in: *prompt_tokens,
tokens_out: *completion_tokens,
});
}
}
_ => {}
true
},
) {
Ok(result) => result,
Err(_stream_err) => {
// Provider doesn't support streaming — fall back to non-streaming
);
let (response, final_usage) = match result {
Ok((msg, u)) => (msg, u.or(usage)),
Err(e) => {
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Generation aborted by user".to_string()));
}
return Ok(());
}
let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming(
&wire_msgs, Some(tc.tdefs.clone()),
)?;
if let Some((tok_in, tok_out)) = usage_fb {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
}
}
(msg, usage_fb)
}
};
if let Some((tok_in, tok_out)) = final_usage {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out });
}
}
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
@@ -640,6 +657,12 @@ fn run_agent_turn(
archive_message(&tc.db, &tc.session_id, &response);
msgs.push(response);
for tool_call in tool_calls {
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error("Turn aborted by user".to_string()));
}
return Ok(());
}
let tool_name = tool_call.function.name.clone();
let args = crate::dto::chat::tool::sanitize_tool_arguments(
&tool_call.function.arguments,
@@ -650,7 +673,7 @@ fn run_agent_turn(
let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name,
&args,
&tc.mode,
&ws_roots,
);
@@ -671,7 +694,7 @@ fn run_agent_turn(
},
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
Verdict::Escalate => (
"Tool requires approval. Switch to Auto mode or provide explicit approval."
"Tool requires approval. Provide explicit approval."
.to_string(),
true,
false,
+6
View File
@@ -43,6 +43,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
message: "transcript cleared".to_string(),
}]
}
Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote {
kind: "error".to_string(),
message: "Usage: /login <provider>".to_string(),
}]
}
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
+47 -26
View File
@@ -45,9 +45,7 @@ impl SseParser {
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
self.buffer = self.buffer[line_end + 1..].to_string();
if line.is_empty() {
if let Some(event) = self.flush_event() {
events.push(event);
}
events.extend(self.flush_event());
} else if let Some(ty) = line.strip_prefix("event: ") {
self.event_type = Some(ty.trim().to_string());
} else if let Some(data) = line.strip_prefix("data: ") {
@@ -59,42 +57,61 @@ impl SseParser {
events
}
fn flush_event(&mut self) -> Option<StreamEvent> {
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
let event_type = self.event_type.take().unwrap_or_default();
if data.is_empty() || data == "[DONE]" {
if data == "[DONE]" {
return Some(StreamEvent::Done);
return vec![StreamEvent::Done];
}
return None;
return vec![];
}
let value: Value = serde_json::from_str(&data).ok()?;
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => return vec![],
};
if let Some(usage) = value.get("usage") {
if !usage.is_null() {
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
.unwrap_or(prompt_tokens + completion_tokens);
return Some(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
}
}
match event_type.as_str() {
"message.stop" => Some(StreamEvent::Done),
"message.start" => None,
"message.stop" => vec![StreamEvent::Done],
"message.start" => vec![],
"message.delta" | "" => {
let delta = value.get("delta").or_else(|| value.get("choices"))?;
let delta = match value.get("delta").or_else(|| value.get("choices")) {
Some(d) => d,
None => return vec![],
};
if let Some(choices) = delta.as_array() {
let choice = choices.first()?;
let delta = choice.get("delta")?;
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
return Some(StreamEvent::Token(content.to_string()));
let choice = match choices.first() {
Some(c) => c,
None => return vec![],
};
let d = match choice.get("delta") {
Some(v) => v,
None => return vec![],
};
// Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
return vec![StreamEvent::Token(content.to_string())];
}
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
return Some(StreamEvent::Reasoning(reasoning.to_string()));
// Reasoning token
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
return vec![StreamEvent::Reasoning(reasoning.to_string())];
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
if let Some(tc) = tool_calls.first() {
// Tool calls — iterate ALL entries, not just first()
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
let mut events = Vec::with_capacity(tool_calls.len());
for tc in tool_calls {
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
let name = tc.get("function")
@@ -106,27 +123,31 @@ impl SseParser {
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
return Some(StreamEvent::ToolCallDelta {
events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
if !events.is_empty() {
return events;
}
}
let finish = choice.get("finish_reason");
if let Some(reason) = finish.and_then(|r| r.as_str()) {
// Finish reason
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
if reason == "stop" || reason == "tool_calls" {
return Some(StreamEvent::Done);
return vec![StreamEvent::Done];
}
}
}
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
return Some(StreamEvent::Token(content.to_string()));
return vec![StreamEvent::Token(content.to_string())];
}
None
vec![]
}
_ => None,
_ => vec![],
}
}
+4 -9
View File
@@ -35,17 +35,12 @@ impl ScrollState {
}
}
pub fn scroll_up(&mut self) {
if self.offset > 0 {
self.offset -= 1;
}
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
pub fn scroll_down(&mut self, total: usize) {
let max_offset = total.saturating_sub(self.max_visible);
if self.offset < max_offset {
self.offset += 1;
}
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
pub fn set_max_visible(&mut self, max: usize) {
+6 -7
View File
@@ -5,7 +5,7 @@ use tokio::sync::RwLock;
use super::misc::{DirCache, InputState, MiscState, ScrollState};
use super::runtime::{SessionRuntime, TurnEvent};
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
use super::types::{Origin, Toast, TranscriptCache};
use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use crate::model::app_config::AppConfig;
@@ -31,7 +31,7 @@ impl ChatMessageDisplay {
#[derive(Clone)]
pub struct AppStateRest {
pub mode: AgentMode,
pub settings: Settings,
pub app_config: AppConfig,
pub workspace_roots: Vec<PathBuf>,
@@ -50,6 +50,7 @@ pub struct AppStateRest {
pub misc: MiscState,
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
pub turn_in_flight: Arc<Mutex<bool>>,
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
pub workflow_engine: WorkflowEngine,
pub mcp_manager: McpManager,
pub dirty: bool,
@@ -68,7 +69,7 @@ impl AppStateRest {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
AppStateRest {
mode: AgentMode::Auto,
settings,
app_config,
workspace_roots,
@@ -79,6 +80,7 @@ impl AppStateRest {
worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)),
edit_log: EditLog::new(&session_dir),
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
@@ -98,10 +100,7 @@ impl AppStateRest {
self.turn_in_flight.lock().map(|g| *g).unwrap_or(false)
}
pub fn set_mode(&mut self, mode: AgentMode) {
self.mode = mode;
self.dirty = true;
}
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg);
+1 -34
View File
@@ -1,36 +1,5 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentMode {
Auto,
Normal,
Plan,
Yolo,
}
impl AgentMode {
pub fn auto_approve(&self) -> bool {
matches!(self, AgentMode::Auto | AgentMode::Yolo)
}
pub fn name(&self) -> &'static str {
match self {
AgentMode::Auto => "Auto",
AgentMode::Normal => "Normal",
AgentMode::Plan => "Plan",
AgentMode::Yolo => "Yolo",
}
}
pub fn cycle(&self) -> AgentMode {
match self {
AgentMode::Normal => AgentMode::Auto,
AgentMode::Auto => AgentMode::Plan,
AgentMode::Plan => AgentMode::Yolo,
AgentMode::Yolo => AgentMode::Normal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
@@ -73,8 +42,7 @@ pub enum Overlay {
Bash,
QuitConfirm,
Workflow,
Onboard,
OnboardProvider,
KeyInput,
Editor,
Effort,
@@ -87,7 +55,6 @@ pub enum Overlay {
Loading,
ModelSelector,
ClearConfirm,
LoginPicker,
}
impl Overlay {
+82 -41
View File
@@ -1,20 +1,50 @@
use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage;
use crate::tool::{all_tools, tool_is_risky};
use crate::dto::provider::request::ToolDef;
use crate::tool::{all_tools, tool_defs, tool_is_risky};
use super::context::SubagentContext;
use super::event::SubagentEvent;
pub const MAX_AGENT_STEPS: usize = 25;
fn tool_call_from_response(response: &str) -> Vec<String> {
let mut calls = Vec::new();
for line in response.lines() {
let trimmed = line.trim();
if let Some(tool_call) = trimmed.strip_prefix("Tool: ") {
calls.push(tool_call.to_string());
/// Maps a subagent's allowed tool names to concrete Tool trait objects and
/// OpenAI-style tool definitions. When `allowed_tools` is empty every tool is
/// available; otherwise only explicitly allowed ones are included.
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
all
} else {
all.into_iter()
.filter(|t| allowed_tools.contains(&t.name().to_string()))
.collect()
};
let defs = tool_defs(&filtered);
(filtered, defs)
}
/// Resolves the API key, model, and base URL from the persisted application
/// configuration rather than environment variables, matching how the main agent
/// resolves its credentials.
fn resolve_provider_config() -> (String, String, Option<String>) {
let settings = crate::model::settings::Settings::load();
let app_config = crate::model::app_config::AppConfig::load();
let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_default();
let model = settings.model.clone();
let base_url = app_config.providers.get(&settings.provider)
.map(|p| p.api_base.clone());
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(&settings.provider) {
api_key = provider_cfg.api_key_env.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_default();
}
}
calls
(api_key, model, base_url)
}
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
@@ -27,14 +57,19 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
.origin(crate::app::state::types::Origin::SubAgent)
.build();
// Build tool list once before the loop
let (tools, tdefs) = build_subagent_tools(&ctx.allowed_tools);
let tdefs_opt: Option<Vec<ToolDef>> = if tdefs.is_empty() { None } else { Some(tdefs) };
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
for step in 0..max_steps {
let api_key = std::env::var("API_KEY").unwrap_or_default();
let model = std::env::var("MODEL").unwrap_or_default();
let (api_key, model, base_url) = resolve_provider_config();
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
let client = crate::service::provider::LlmClient::new(api_key, model, None);
let response = match client.chat(&messages) {
Ok(r) => r,
// Use the structured tool-calling API so the LLM can request tools with
// proper arguments, exactly like the main agent does.
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
Ok(result) => result,
Err(e) => {
let _ = tx.blocking_send(SubagentEvent::StepFailed {
_step: step,
@@ -44,31 +79,30 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
}
};
let _ = tx.blocking_send(SubagentEvent::ToolCall {
_tool: "api".to_string(),
_args: serde_json::json!({"response": response}),
});
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
let tool_calls = tool_call_from_response(&response);
if tool_calls.is_empty() {
output.push_str(&response);
output.push('\n');
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
_step: step,
_output: response.clone(),
});
if !response.contains("Tool:") {
break;
}
} else {
let tools = all_tools();
for tool_name in &tool_calls {
let content = response.content.clone().unwrap_or_default();
if has_tool_calls {
let tool_calls = response.tool_calls.clone().unwrap_or_default();
// Push the assistant message with tool_calls into the conversation
messages.push(response);
for tool_call in &tool_calls {
let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
let _ = tx.blocking_send(SubagentEvent::ToolCall {
_tool: tool_name.clone(),
_args: args.clone(),
});
if !generally_allowed {
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
@@ -78,7 +112,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
if tool_is_risky(tool_name) && !explicitly_allowed {
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
@@ -86,13 +120,14 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
continue;
}
let result = match tools.iter().find(|t| t.name() == *tool_name) {
Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})),
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
Some(tool) => tool.run(&tool_ctx, &args),
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
};
match result {
Ok(output_text) => {
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: output_text,
@@ -100,6 +135,7 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
}
Err(e) => {
let msg = format!("tool '{}' failed: {}", tool_name, e);
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
_tool: tool_name.clone(),
_output: msg,
@@ -107,16 +143,21 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
}
}
}
} else {
// Text-only response — accumulate and finish
if !content.is_empty() {
output.push_str(&content);
output.push('\n');
}
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
_step: step,
_output: response.clone(),
_output: content.clone(),
});
// Break only when we got real content; empty means something went wrong
if !content.is_empty() {
break;
}
}
let assistant_msg = ChatMessage::assistant(Some(response.clone()));
messages.push(assistant_msg);
let user_msg = ChatMessage::user("Continue with the next step based on the tool results above.".to_string());
messages.push(user_msg);
}
let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() });
+15
View File
@@ -22,8 +22,23 @@ impl AgentDefinition {
}
}
pub fn with_system_prompt(mut self, prompt: String) -> Self {
self.system_prompt = Some(prompt);
self
}
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = Some(tools);
self
}
pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps);
self
}
pub fn with_temperature(mut self, temp: f32) -> Self {
self.temperature = Some(temp);
self
}
}
+1 -1
View File
@@ -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
View File
@@ -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(),
}
}
-1
View File
@@ -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
View File
@@ -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);
}
}
_ => {}
}
}
+1 -1
View File
@@ -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
View File
@@ -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<()> {
+2 -2
View File
@@ -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
View File
@@ -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));
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+4 -5
View File
@@ -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(