From 3dee2a14273dd654c88bf6c897d9325d4537d101 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 11 Jul 2026 22:10:17 +0700 Subject: [PATCH] Refactor API integration and enhance command handling - Removed unused modules and updated module paths for clarity. - Added autocomplete functionality for command input in InputState. - Updated AppStateRest to include a method for checking if a turn is in flight. - Refactored subagent engine to use new API client structure. - Changed default provider from "openrouter" to "zen" with updated API keys and models. - Implemented tests for memory management and edit log functionalities. - Enhanced error handling in API requests and improved response parsing. - Updated UI components to reflect new API provider and status indicators. --- src-misc/quality-reviewer-prompt.txt | 28 ++- src-misc/system-prompt.txt | 27 ++- src/app/catastrophic.rs | 143 +++++++++++++++- src/app/harness.rs | 120 +++++++++++++ src/app/mode/onboard_provider.rs | 7 +- src/app/runtime/actions/mod.rs | 45 +++-- src/app/runtime/stream/mod.rs | 2 - src/app/state/misc.rs | 53 ++++++ src/app/state/rest.rs | 4 + src/app/subagent/engine.rs | 6 +- src/controller/command.rs | 2 +- src/controller/input.rs | 4 + src/dto/mod.rs | 2 +- src/dto/{openrouter => provider}/mod.rs | 0 src/dto/{openrouter => provider}/request.rs | 0 src/dto/{openrouter => provider}/response.rs | 0 src/dto/{openrouter => provider}/usage.rs | 0 src/main.rs | 79 --------- src/model/app_config.rs | 16 +- src/model/editlog.rs | 73 ++++++++ src/model/memory.rs | 171 ++++++++++++++++++- src/model/settings.rs | 4 +- src/service/mod.rs | 2 +- src/service/openrouter.rs | 66 ------- src/service/provider.rs | 94 ++++++++++ src/tool/fs/helpers.rs | 36 ++++ src/tool/mod.rs | 6 +- src/view/chat.rs | 11 ++ src/view/mod.rs | 2 +- src/view/status.rs | 7 +- 30 files changed, 788 insertions(+), 222 deletions(-) rename src/dto/{openrouter => provider}/mod.rs (100%) rename src/dto/{openrouter => provider}/request.rs (100%) rename src/dto/{openrouter => provider}/response.rs (100%) rename src/dto/{openrouter => provider}/usage.rs (100%) delete mode 100644 src/service/openrouter.rs create mode 100644 src/service/provider.rs diff --git a/src-misc/quality-reviewer-prompt.txt b/src-misc/quality-reviewer-prompt.txt index 07d853f..246ea15 100644 --- a/src-misc/quality-reviewer-prompt.txt +++ b/src-misc/quality-reviewer-prompt.txt @@ -1,25 +1,17 @@ -You are a code quality reviewer for Zesdex. Review recent code changes -for correctness, security, and adherence to best practices. +You are a code quality reviewer for Zesdex. Review recent code changes for correctness, security, and adherence to best practices. -You have read-only access to the workspace. Use read, grep, glob, recall, -and remember tools to inspect files and save observations. +You have read-only access to the workspace. Use read, grep, glob, recall, and remember tools to inspect files and save observations. Review guidelines: -1. Check for common bugs: null/panic paths, off-by-one, race conditions, - unhandled errors, logic errors. -2. Check security: injection risks, unsafe deserialization, credential - exposure, path traversal. -3. Check conventions: does the code follow existing patterns in the - codebase? Check surrounding files for naming, structure, style. -4. Check the reason against the actual diff — does the reason match - what the code does? +1. Check for correctness and real utility: Ensure the code contains absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or incomplete logic). Every code path must be fully implemented, functional, and deterministic. Verify that no dead code or redundant structures are introduced under the guise of efficiency. +2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws. +3. Check security: Look for injection risks, unsafe deserialization, credential exposure, and path traversal vulnerabilities. +4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture. +5. Check intent against diff: Does the actual implementation match what the code is intended to do? -If you find something worth remembering, call remember() with type="lesson". -Only call remember() if the observation is non-obvious and would benefit -future turns. Skip trivial style nits. +If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits. -Before writing a new lesson, call recall() to check if a similar lesson -already exists. Deduplicate — don't write the same lesson twice. +Before writing a new lesson, call recall() to check if a similar lesson already exists. Deduplicate — don't write the same lesson twice. Output: a one-line verdict summarizing your review. -Include "N lesson(s)" at the end if you created lessons. +Include "N lesson(s)" at the end if you created lessons. \ No newline at end of file diff --git a/src-misc/system-prompt.txt b/src-misc/system-prompt.txt index 2315693..b605154 100644 --- a/src-misc/system-prompt.txt +++ b/src-misc/system-prompt.txt @@ -1,20 +1,15 @@ -You are Zesdex, an autonomous AI coding and security agent operating in a -terminal-based TUI environment. Your goal is to help the user accomplish -software engineering tasks efficiently. +You are Zesdex, an autonomous AI coding and security agent operating in a terminal-based TUI environment. Your goal is to help the user accomplish software engineering tasks with absolute correctness and real utility. Core principles: 1. Be concise but thorough — prefer showing results over describing them. -2. Use the tools available to explore, understand, and modify the codebase. -3. For simple tasks, handle them directly with read/grep/write/edit. -4. For complex tasks (multi-file changes, parallel analysis, independent - verification), use workflow_run to orchestrate sub-agents. -5. Every write or edit must have a clear reason — include it in the reason - parameter. -6. When you're uncertain about requirements, ask clarifying questions - before acting. -7. After making changes, verify they work by running builds or tests. -8. Respect the agent mode: Auto (full autonomy), Normal (review risky ops), - Plan (no mutations), Yolo (full autonomy + no classifier). +2. Deliver production-ready code — ensure absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or unfinished logic). Every code path must be fully implemented, functional, and deterministic. No dead code or redundant structures are allowed. +3. Clean and self-documenting code — strictly emit NO comments inside the code blocks. The logic must speak for itself through precise naming, strong typing, and clean architecture. +4. Use the tools available to explore, understand, and modify the codebase. +5. For simple tasks, handle them directly with read/grep/write/edit. +6. For complex tasks (multi-file changes, parallel analysis, independent verification), use workflow_run to orchestrate sub-agents. +7. Every write or edit must have a clear reason — include it in the reason parameter. +8. When you're uncertain about requirements, ask clarifying questions before acting. +9. After making changes, verify they work by running builds or tests. +10. Respect the agent mode: Auto (full autonomy), Normal (review risky ops), Plan (no mutations), Yolo (full autonomy + no classifier). -Available tools are described in the system-tools.txt section. Use them -judiciously — prefer the simplest tool that accomplishes the task. +Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task. \ No newline at end of file diff --git a/src/app/catastrophic.rs b/src/app/catastrophic.rs index 99635a9..0696363 100644 --- a/src/app/catastrophic.rs +++ b/src/app/catastrophic.rs @@ -1,5 +1,146 @@ use std::path::Path; +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_allow_safe_git_ops() { + assert!(CatastrophicGuard::check_git_operation("git commit -m 'fix'").is_ok()); + assert!(CatastrophicGuard::check_git_operation("git push origin main").is_ok()); + assert!(CatastrophicGuard::check_git_operation("git pull").is_ok()); + assert!(CatastrophicGuard::check_git_operation("git status").is_ok()); + assert!(CatastrophicGuard::check_git_operation("git log --oneline").is_ok()); + } + + #[test] + fn test_block_force_push() { + assert!(CatastrophicGuard::check_git_operation("git push --force origin main").is_err()); + assert!(CatastrophicGuard::check_git_operation("git push +refs/heads/main").is_err()); + assert!(CatastrophicGuard::check_git_operation("git push origin :main").is_err()); + } + + #[test] + fn test_block_reset_hard() { + assert!(CatastrophicGuard::check_git_operation("git reset --hard HEAD~1").is_err()); + assert!(CatastrophicGuard::check_git_operation("git reset --hard origin/main").is_err()); + } + + #[test] + fn test_block_git_clean() { + assert!(CatastrophicGuard::check_git_operation("git clean -fd").is_err()); + assert!(CatastrophicGuard::check_git_operation("git clean -xdf").is_err()); + } + + #[test] + fn test_block_branch_force_delete() { + assert!(CatastrophicGuard::check_git_operation("git branch -D feature").is_err()); + assert!(CatastrophicGuard::check_git_operation("git branch --delete --force main").is_err()); + } + + #[test] + fn test_block_force_checkout() { + assert!(CatastrophicGuard::check_git_operation("git checkout --force other").is_err()); + } + + #[test] + fn test_block_stash_destructive() { + assert!(CatastrophicGuard::check_git_operation("git stash drop").is_err()); + assert!(CatastrophicGuard::check_git_operation("git stash clear").is_err()); + } + + #[test] + fn test_block_filter_branch() { + assert!(CatastrophicGuard::check_git_operation("git filter-branch --force").is_err()); + } + + #[test] + fn test_block_gc_prune() { + assert!(CatastrophicGuard::check_git_operation("git gc --prune=now").is_err()); + } + + #[test] + fn test_allow_safe_shell() { + assert!(CatastrophicGuard::check_shell_command("ls -la /tmp").is_ok()); + assert!(CatastrophicGuard::check_shell_command("echo hello").is_ok()); + assert!(CatastrophicGuard::check_shell_command("cat /etc/hostname").is_ok()); + assert!(CatastrophicGuard::check_shell_command("cargo build").is_ok()); + } + + #[test] + fn test_block_dd() { + assert!(CatastrophicGuard::check_shell_command("dd if=/dev/zero of=/dev/sda").is_err()); + } + + #[test] + fn test_block_format() { + assert!(CatastrophicGuard::check_shell_command("mkfs.ext4 /dev/sdb1").is_err()); + assert!(CatastrophicGuard::check_shell_command("format /dev/sdc").is_err()); + } + + #[test] + fn test_block_shutdown_reboot() { + assert!(CatastrophicGuard::check_shell_command("shutdown -h now").is_err()); + assert!(CatastrophicGuard::check_shell_command("reboot").is_err()); + assert!(CatastrophicGuard::check_shell_command("poweroff").is_err()); + } + + #[test] + fn test_block_system_directory_delete() { + let p = Path::new("/"); + assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err()); + let p = Path::new("/home"); + assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err()); + } + + #[test] + fn test_credential_pattern_blocked() { + assert!(CatastrophicGuard::check_credential_pattern("cat ~/.ssh/id_rsa").is_err()); + assert!(CatastrophicGuard::check_credential_pattern("cat .git-credentials").is_err()); + assert!(CatastrophicGuard::check_credential_pattern("cat ~/.netrc").is_err()); + } + + #[test] + fn test_credential_pattern_allowed() { + assert!(CatastrophicGuard::check_credential_pattern("cat README.md").is_ok()); + assert!(CatastrophicGuard::check_credential_pattern("ls -la").is_ok()); + } + + #[test] + fn test_download_path_sensitive() { + let sensitive = Path::new("/tmp/id_rsa"); + assert!(CatastrophicGuard::check_download_path(sensitive).is_err()); + let sensitive = Path::new("/tmp/credentials.json"); + assert!(CatastrophicGuard::check_download_path(sensitive).is_err()); + } + + #[test] + fn test_download_path_allowed() { + let safe = Path::new("/tmp/report.pdf"); + assert!(CatastrophicGuard::check_download_path(safe).is_ok()); + } + + #[test] + fn test_check_all_blocks_destructive() { + assert!(CatastrophicGuard::check_all("git push --force origin main", &[]).is_err()); + assert!(CatastrophicGuard::check_all("dd if=/dev/zero of=/dev/sda", &[]).is_err()); + } + + #[test] + fn test_check_all_allows_safe() { + assert!(CatastrophicGuard::check_all("git commit -m 'fix'", &[]).is_ok()); + assert!(CatastrophicGuard::check_all("cargo build", &[]).is_ok()); + } + + #[test] + fn test_delete_outside_workspace() { + let workspace = Path::new("/tmp/test_ws"); + let outside = Path::new("/etc/passwd"); + assert!(CatastrophicGuard::check_delete_path(outside, &[workspace]).is_err()); + } +} + pub struct CatastrophicGuard; impl CatastrophicGuard { @@ -10,7 +151,7 @@ impl CatastrophicGuard { "clean -f", "clean -d", "clean -x", - "branch -D", + "branch -d", "branch --delete --force", "checkout --force", "switch -f", diff --git a/src/app/harness.rs b/src/app/harness.rs index d7daeb5..fa111fa 100644 --- a/src/app/harness.rs +++ b/src/app/harness.rs @@ -115,3 +115,123 @@ impl Default for Harness { Harness } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::state::types::AgentMode; + use serde_json::json; + + #[test] + fn test_verdict_is_allowed() { + assert!(Verdict::Allow.is_allowed()); + assert!(!Verdict::Block("test".to_string()).is_allowed()); + assert!(!Verdict::Escalate.is_allowed()); + } + + #[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); + } + + #[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); + 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); + 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); + 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(_))); + } + + #[test] + fn test_parse_verdict_json_allow() { + let v = parse_verdict(r#"{"verdict": "allow"}"#); + assert_eq!(v, Some(Verdict::Allow)); + } + + #[test] + fn test_parse_verdict_json_block() { + let v = parse_verdict(r#"{"verdict": "block", "reason": "dangerous operation"}"#); + assert_eq!(v, Some(Verdict::Block("dangerous operation".to_string()))); + } + + #[test] + fn test_parse_verdict_json_escalate() { + let v = parse_verdict(r#"{"verdict": "escalate"}"#); + assert_eq!(v, Some(Verdict::Escalate)); + } + + #[test] + fn test_parse_verdict_text_allow() { + let v = parse_verdict("Verdict: Allow"); + assert_eq!(v, Some(Verdict::Allow)); + } + + #[test] + fn test_parse_verdict_text_block() { + let v = parse_verdict("Verdict: Block - this operation is not allowed"); + assert!(matches!(v, Some(Verdict::Block(_)))); + } + + #[test] + fn test_parse_verdict_fallback_allow() { + let v = parse_verdict("I think we should allow this operation"); + assert_eq!(v, Some(Verdict::Allow)); + } + + #[test] + fn test_parse_verdict_fallback_block() { + let v = parse_verdict("This request should be blocked"); + assert!(matches!(v, Some(Verdict::Block(_)))); + } + + #[test] + fn test_parse_verdict_unparseable() { + let v = parse_verdict("completely unrelated text with no keywords"); + assert_eq!(v, None); + } +} diff --git a/src/app/mode/onboard_provider.rs b/src/app/mode/onboard_provider.rs index 2f778a4..49224ab 100644 --- a/src/app/mode/onboard_provider.rs +++ b/src/app/mode/onboard_provider.rs @@ -1,13 +1,12 @@ use crate::model::settings::Settings; -pub const PROVIDERS: &[&str] = &["OpenRouter", "Anthropic", "OpenAI"]; +pub const PROVIDERS: &[&str] = &["Zen API", "OpenAI"]; pub fn set_provider(settings: &mut Settings, provider: &str) { settings.provider = match provider { - "OpenRouter" => "openrouter".to_string(), - "Anthropic" => "anthropic".to_string(), + "Zen API" => "zen".to_string(), "OpenAI" => "openai".to_string(), - _ => "openrouter".to_string(), + _ => "zen".to_string(), }; } diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 1432796..4a4c65b 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -397,7 +397,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } } TurnEvent::Error(msg) => { - state.push_toast(Toast::new(ToastKind::Error, msg)); + let long_toast = Toast { + kind: ToastKind::Error, + message: msg.clone(), + created_at: chrono::Utc::now().timestamp_millis(), + lifetime_ms: 15000, + }; + state.push_toast(long_toast); + state.push_transcript(ChatMessageDisplay::new( + crate::dto::chat::message::Role::System, + format!("Error: {}", msg), + )); turn_finished = true; } TurnEvent::Done => { @@ -453,16 +463,13 @@ fn spawn_turn(state: &AppStateRest) { return; } let api_key = state.settings.api_key.clone().unwrap_or_default(); - if api_key.is_empty() { - return; - } let model = state.settings.model.clone(); let mut tools = crate::tool::all_tools(); 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_log_path = state.edit_log.path.clone(); + 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(); @@ -474,13 +481,13 @@ fn spawn_turn(state: &AppStateRest) { std::thread::spawn(move || { let tc = TurnCtx { - client: crate::service::openrouter::OpenRouterClient::new(api_key, model), + client: crate::service::provider::LlmClient::new(api_key, model), tdefs: tool_defs, tools, ctx, mode, workspace_roots, - edit_log_path, + edit_log_session_dir: edit_session_dir, session_id, }; let result = run_agent_turn(tc, &messages, &events_q); @@ -496,13 +503,13 @@ fn spawn_turn(state: &AppStateRest) { } struct TurnCtx { - client: crate::service::openrouter::OpenRouterClient, - tdefs: Vec, + client: crate::service::provider::LlmClient, + tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, mode: AgentMode, workspace_roots: Vec, - edit_log_path: std::path::PathBuf, + edit_log_session_dir: std::path::PathBuf, session_id: String, } @@ -548,7 +555,7 @@ fn run_agent_turn( &tc.ctx, &tool_name, &args, - &tc.edit_log_path, + &tc.edit_log_session_dir, &tc.session_id, ) { Ok(result) => (result, false, is_edit_tool), @@ -612,7 +619,7 @@ fn execute_one_tool( ctx: &crate::tool::ToolCtx, name: &str, args: &serde_json::Value, - edit_log_path: &std::path::Path, + session_dir: &std::path::Path, session_id: &str, ) -> anyhow::Result { for tool in tools { @@ -634,17 +641,27 @@ fn execute_one_tool( ); format!("{:x}", hash) }; + let bytes_delta = if name == "write" { + args.get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len() as i64) + .unwrap_or(0) + } else { + let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); + let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); + (new.len() as i64 - old.len() as i64).abs() + }; let entry = crate::model::editlog::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), tool: name.to_string(), path: path.to_string(), reason: reason.to_string(), content_sha256, - bytes_delta: result.len() as i64, + bytes_delta, origin: ctx.origin.tag(), session_id: session_id.to_string(), }; - let mut el = crate::model::editlog::EditLog::new(edit_log_path); + let mut el = crate::model::editlog::EditLog::new(session_dir); el.append(entry).ok(); } return Ok(result); diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs index 1f24c02..e69de29 100644 --- a/src/app/runtime/stream/mod.rs +++ b/src/app/runtime/stream/mod.rs @@ -1,2 +0,0 @@ -pub mod tools; -pub mod turn; diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index f818fac..1344fcd 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -68,8 +68,28 @@ pub struct InputState { pub cursor: usize, pub history: Vec, pub history_idx: Option, + pub autocomplete_prefix: String, + pub autocomplete_candidates: Vec, + pub autocomplete_idx: usize, } +const COMMANDS: &[&str] = &[ + "/help", + "/quit", + "/resume", + "/clear", + "/save", + "/lesson", + "/lesson ls", + "/lesson export", + "/lesson import", + "/mode chat", + "/mode bash", + "/mode help", + "/mode settings", + "/login", +]; + impl InputState { pub fn new() -> Self { InputState { @@ -77,6 +97,39 @@ impl InputState { cursor: 0, history: Vec::new(), history_idx: None, + autocomplete_prefix: String::new(), + autocomplete_candidates: Vec::new(), + autocomplete_idx: 0, + } + } + + pub fn tab_complete(&mut self) { + let trimmed = self.buffer.trim().to_string(); + if trimmed.is_empty() { + return; + } + + if !trimmed.starts_with('/') { + return; + } + + let prefix = trimmed.to_lowercase(); + + if prefix != self.autocomplete_prefix || self.autocomplete_candidates.is_empty() { + self.autocomplete_candidates = COMMANDS + .iter() + .filter(|c| c.starts_with(&prefix)) + .map(|c| c.to_string()) + .collect(); + self.autocomplete_prefix = prefix; + self.autocomplete_idx = 0; + } else { + self.autocomplete_idx = (self.autocomplete_idx + 1) % self.autocomplete_candidates.len(); + } + + if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { + self.buffer = candidate.clone(); + self.cursor = self.buffer.len(); } } diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index 13ab990..6c0c031 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -106,6 +106,10 @@ impl AppStateRest { self.mode } + pub fn turn_in_flight(&self) -> bool { + 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; diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index 05de7d0..ba5ea50 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -29,10 +29,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> an let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS); for step in 0..max_steps { - let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default(); - let model = std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-5".to_string()); + let api_key = std::env::var("API_KEY").unwrap_or_default(); + let model = std::env::var("MODEL").unwrap_or_default(); - let client = crate::service::openrouter::OpenRouterClient::new(api_key, model); + let client = crate::service::provider::LlmClient::new(api_key, model); let response = match client.chat(&messages) { Ok(r) => r, Err(e) => { diff --git a/src/controller/command.rs b/src/controller/command.rs index 25cb464..8f77e03 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -53,7 +53,7 @@ pub fn parse_command(text: &str) -> Command { "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" => Command::LessonList, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, - "/login" => Command::Login { provider: "openrouter".to_string() }, + "/login" => Command::Login { provider: "zen".to_string() }, _ => Command::Unknown(cmd.to_string()), } } diff --git a/src/controller/input.rs b/src/controller/input.rs index adac0d4..ebac232 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -89,6 +89,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } KeyCode::Tab => { + if state.input.buffer.starts_with('/') { + state.input.tab_complete(); + state.dirty = true; + } Vec::new() } KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { diff --git a/src/dto/mod.rs b/src/dto/mod.rs index 5e12abc..01660d1 100644 --- a/src/dto/mod.rs +++ b/src/dto/mod.rs @@ -1,2 +1,2 @@ pub mod chat; -pub mod openrouter; +pub mod provider; diff --git a/src/dto/openrouter/mod.rs b/src/dto/provider/mod.rs similarity index 100% rename from src/dto/openrouter/mod.rs rename to src/dto/provider/mod.rs diff --git a/src/dto/openrouter/request.rs b/src/dto/provider/request.rs similarity index 100% rename from src/dto/openrouter/request.rs rename to src/dto/provider/request.rs diff --git a/src/dto/openrouter/response.rs b/src/dto/provider/response.rs similarity index 100% rename from src/dto/openrouter/response.rs rename to src/dto/provider/response.rs diff --git a/src/dto/openrouter/usage.rs b/src/dto/provider/usage.rs similarity index 100% rename from src/dto/openrouter/usage.rs rename to src/dto/provider/usage.rs diff --git a/src/main.rs b/src/main.rs index c8246fa..d8fd2fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,43 +73,6 @@ fn run_single_process() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.clear()?; - let ctx = tool::ToolCtx::builder() - .workspaces(state.workspace_roots.clone()) - .session_dir(state.session_dir.clone()) - .memory_dir(state.memory_dir.clone()) - .download_dir(state.download_dir.clone()) - .worktrees_dir(state.worktrees_dir.clone()) - .internet_mode(state.settings.internet_mode.clone()) - .origin(crate::app::state::types::Origin::Main) - .build(); - let _ = &ctx.session_dir; - let _ = &ctx.memory_dir; - let _ = &ctx.download_dir; - let _ = &ctx.dir_cache; - let _ = &ctx.origin; - let _ = &ctx.graduated_checks; - - let _ = tool::GraduatedCheck { name: "test".to_string(), pattern: "test".to_string(), rule: "test".to_string() }; - let _ = tool::check_graduated_checks("/tmp/test", "content", &[]); - - let _tools = tool::all_tools(); - for _t in &_tools { - let _ = _t.name(); - let _ = _t.description(); - let _ = _t.parameters(); - let _ = _t.run(&ctx, &serde_json::json!({})); - } - - let _ = tool::tool_is_risky("read"); - let _ = tool::tool_is_risky("write"); - let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/"); - let _ = tool::DEFERRED_TOOLS; - let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test"); - let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test"); - let _ = tool::shell_filter::credentials::check_credential_read("echo safe"); - let _ = tool::shell_filter::git::check_git_destructive("git push"); - let _ = tool::shell_filter::git::check_git_destructive("git status"); - let run_result = run_loop(&mut state, &mut terminal); let mut restore_stdout = io::stdout(); @@ -122,9 +85,6 @@ fn run_single_process() -> Result<()> { } let _ = state.settings.save(); - core::mem::drop(ctx); - core::mem::drop(_tools); - core::mem::drop(_rt); Ok(()) } @@ -310,42 +270,6 @@ fn run_daemon() -> Result<()> { let _rt = tokio::runtime::Runtime::new()?; - let _ctx = tool::ToolCtx::builder() - .workspaces(state.workspace_roots.clone()) - .session_dir(state.session_dir.clone()) - .memory_dir(state.memory_dir.clone()) - .download_dir(state.download_dir.clone()) - .worktrees_dir(state.worktrees_dir.clone()) - .internet_mode(state.settings.internet_mode.clone()) - .origin(crate::app::state::types::Origin::Main) - .build(); - - let _ = &_ctx.session_dir; - let _ = &_ctx.memory_dir; - let _ = &_ctx.download_dir; - let _ = &_ctx.dir_cache; - let _ = &_ctx.origin; - let _ = &_ctx.graduated_checks; - - let _tools = tool::all_tools(); - for _t in &_tools { - let _ = _t.name(); - let _ = _t.description(); - let _ = _t.parameters(); - let _ = _t.run(&_ctx, &serde_json::json!({})); - } - - let _ = tool::tool_is_risky("read"); - let _ = tool::tool_is_risky("write"); - let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/"); - let _ = tool::DEFERRED_TOOLS; - let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test"); - let _ = tool::fs::helpers::not_found_help(&_ctx, std::path::Path::new("/nonexistent"), "test"); - - let _ = tool::shell_filter::credentials::check_credential_read("echo safe"); - let _ = tool::shell_filter::git::check_git_destructive("git push"); - let _ = tool::shell_filter::git::check_git_destructive("git status"); - let run_dir = store.base_dir.join("run"); std::fs::create_dir_all(&run_dir)?; let socket_path = run_dir.join(format!("{}.sock", session_id)); @@ -417,9 +341,6 @@ fn run_daemon() -> Result<()> { let _ = std::fs::remove_file(&socket_path); let _ = state.settings.save(); - core::mem::drop(_ctx); - core::mem::drop(_tools); - core::mem::drop(_rt); Ok(()) } diff --git a/src/model/app_config.rs b/src/model/app_config.rs index d87856e..bda12de 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -27,23 +27,23 @@ pub struct ModelRole { impl Default for AppConfig { fn default() -> Self { let mut providers = HashMap::new(); - providers.insert("openrouter".to_string(), ProviderConfig { - api_base: "https://openrouter.ai/api/v1".to_string(), - api_key_env: Some("OPENROUTER_API_KEY".to_string()), - default_model: Some("anthropic/claude-opus-4-8".to_string()), + providers.insert("zen".to_string(), ProviderConfig { + api_base: "https://opencode.ai/zen/v1".to_string(), + api_key_env: Some("API_KEY".to_string()), + default_model: Some("deepseek-v4-flash-free".to_string()), }); let mut model_roles = HashMap::new(); model_roles.insert("default".to_string(), ModelRole { - provider: "openrouter".to_string(), - model: "anthropic/claude-opus-4-8".to_string(), + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), max_tokens: Some(8192), temperature: Some(0.7), }); AppConfig { providers, model_roles, - default_provider: "openrouter".to_string(), - default_model: "anthropic/claude-opus-4-8".to_string(), + default_provider: "zen".to_string(), + default_model: "deepseek-v4-flash-free".to_string(), } } } diff --git a/src/model/editlog.rs b/src/model/editlog.rs index 8d63c1b..b31c47d 100644 --- a/src/model/editlog.rs +++ b/src/model/editlog.rs @@ -62,3 +62,76 @@ impl EditLog { self.entries.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_editlog_new_empty() { + let dir = std::env::temp_dir().join("editlog_test"); + let _ = std::fs::create_dir_all(&dir); + let log = EditLog::new(&dir); + assert_eq!(log.len(), 0); + assert_eq!(log.recent(5).len(), 0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_editlog_append_and_reload() { + let dir = std::env::temp_dir().join("editlog_append_test"); + let _ = std::fs::create_dir_all(&dir); + let mut log = EditLog::new(&dir); + let entry = EditLogEntry { + ts: 1, + tool: "write".to_string(), + path: "test.txt".to_string(), + reason: "test reason".to_string(), + content_sha256: "abc123".to_string(), + bytes_delta: 42, + origin: "main".to_string(), + session_id: "sess-1".to_string(), + }; + log.append(entry.clone()).unwrap(); + assert_eq!(log.len(), 1); + + let loaded = EditLog::load(&log.path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.entries[0].reason, "test reason"); + assert_eq!(loaded.entries[0].tool, "write"); + assert_eq!(loaded.entries[0].path, "test.txt"); + + let recent = log.recent(1); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].bytes_delta, 42); + + let empty = log.recent(0); + assert_eq!(empty.len(), 0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_editlog_multiple_entries() { + let dir = std::env::temp_dir().join("editlog_multiple_test"); + let _ = std::fs::create_dir_all(&dir); + let mut log = EditLog::new(&dir); + for i in 0..5 { + log.append(EditLogEntry { + ts: i, + tool: "edit".to_string(), + path: format!("file{}.txt", i), + reason: format!("reason {}", i), + content_sha256: "hash".to_string(), + bytes_delta: 10 + i, + origin: "main".to_string(), + session_id: "sess-1".to_string(), + }).unwrap(); + } + assert_eq!(log.len(), 5); + let recent = log.recent(3); + assert_eq!(recent.len(), 3); + assert_eq!(recent[0].reason, "reason 2"); + assert_eq!(recent[2].reason, "reason 4"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/model/memory.rs b/src/model/memory.rs index 67048b0..2627c41 100644 --- a/src/model/memory.rs +++ b/src/model/memory.rs @@ -74,7 +74,8 @@ impl Memory { } pub fn parse(content: &str) -> std::io::Result { - let parts: Vec<&str> = content.splitn(2, "---\n").collect(); + let content = content.strip_prefix("---\n").unwrap_or(content); + let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); if parts.len() < 2 { return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter")); } @@ -218,6 +219,174 @@ pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std:: Ok(Some(retrospective)) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slugify_empty() { + assert_eq!(Memory::slugify(""), None); + } + + #[test] + fn test_slugify_basic() { + assert_eq!(Memory::slugify("Hello World"), Some("hello-world".to_string())); + } + + #[test] + fn test_slugify_special_chars() { + assert_eq!(Memory::slugify("Use & Avoid! @#$"), Some("use-avoid".to_string())); + } + + #[test] + fn test_slugify_too_long() { + let long = "a".repeat(100); + assert_eq!(Memory::slugify(&long), None); + } + + #[test] + fn test_slugify_numeric() { + assert_eq!(Memory::slugify("123"), Some("123".to_string())); + } + + #[test] + fn test_memory_parse_basic() { + let md = "---\nname: test-memory\ndescription: A test memory\nkind: lesson\ncreated_at: 1000\nupdated_at: 2000\n---\n\nThis is the body."; + let mem = Memory::parse(md).unwrap(); + assert_eq!(mem.name, "test-memory"); + assert_eq!(mem.description, "A test memory"); + assert_eq!(mem.kind, "lesson"); + assert_eq!(mem.created_at, 1000); + assert_eq!(mem.updated_at, 2000); + assert_eq!(mem.content, "This is the body."); + } + + #[test] + fn test_memory_parse_with_optional_fields() { + let md = "---\nname: full-memory\ndescription: Full fields\ntype: reference\ncreated_at: 100\nupdated_at: 200\nlifecycle: active\nscope: project\n---\n\nBody content here."; + let mem = Memory::parse(md).unwrap(); + assert_eq!(mem.name, "full-memory"); + assert_eq!(mem.lifecycle, "active"); + assert_eq!(mem.scope, Some("project".to_string())); + assert_eq!(mem.content, "Body content here."); + } + + #[test] + fn test_memory_parse_missing_frontmatter() { + let md = "No frontmatter here"; + assert!(Memory::parse(md).is_err()); + } + + #[test] + fn test_memory_write_and_read() { + let dir = std::env::temp_dir().join("memory_test_write_read"); + let _ = std::fs::create_dir_all(&dir); + let mem = Memory { + name: "my-test".to_string(), + description: "Test".to_string(), + content: "Some content".to_string(), + kind: "reference".to_string(), + created_at: 42, + updated_at: 43, + outcome: None, + lifecycle: "new".to_string(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(&dir).unwrap(); + let read = Memory::read(&dir, "my-test").unwrap(); + assert_eq!(read.name, "my-test"); + assert_eq!(read.content, "Some content"); + assert_eq!(read.created_at, 42); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_memory_list() { + let dir = std::env::temp_dir().join("memory_test_list"); + let _ = std::fs::create_dir_all(&dir); + let mem = Memory { + name: "alpha".to_string(), + description: "A".to_string(), + content: "a".to_string(), + kind: "lesson".to_string(), + created_at: 1, + updated_at: 1, + outcome: None, + lifecycle: "new".to_string(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(&dir).unwrap(); + let names = Memory::list(&dir); + assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_memory_remove() { + let dir = std::env::temp_dir().join("memory_test_remove"); + let _ = std::fs::create_dir_all(&dir); + let mem = Memory { + name: "remove-me".to_string(), + description: "R".to_string(), + content: "r".to_string(), + kind: "lesson".to_string(), + created_at: 1, + updated_at: 1, + outcome: None, + lifecycle: "new".to_string(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(&dir).unwrap(); + assert!(Memory::read(&dir, "remove-me").is_ok()); + Memory::remove(&dir, "remove-me").unwrap(); + assert!(Memory::read(&dir, "remove-me").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_export_import_lessons() { + let dir = std::env::temp_dir().join("memory_test_export"); + let _ = std::fs::create_dir_all(&dir); + let mem = Memory { + name: "export-me".to_string(), + description: "Exported".to_string(), + content: "content".to_string(), + kind: "lesson".to_string(), + created_at: 10, + updated_at: 10, + outcome: None, + lifecycle: "active".to_string(), + scope: Some("project".to_string()), + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(&dir).unwrap(); + + let export_path = std::env::temp_dir().join("memory_test_export_lessons.json"); + export_lessons(&dir, &export_path).unwrap(); + assert!(export_path.exists()); + + let dest_dir = std::env::temp_dir().join("memory_test_import_dest"); + let _ = std::fs::create_dir_all(&dest_dir); + let imported = import_lessons(&dest_dir, &export_path).unwrap(); + assert_eq!(imported, 1); + assert!(Memory::read(&dest_dir, "export-me").is_ok()); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dest_dir); + let _ = std::fs::remove_file(&export_path); + } +} + pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result { let now = chrono::Utc::now().timestamp_millis(); let lessons_content: String = lessons.iter() diff --git a/src/model/settings.rs b/src/model/settings.rs index 0834469..c1975b7 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -45,8 +45,8 @@ impl Default for Settings { fn default() -> Self { Settings { internet_mode: InternetMode::Off, - provider: "openrouter".to_string(), - model: "anthropic/claude-opus-4-8".to_string(), + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), api_key: None, max_tokens: 8192, temperature: 0.7, diff --git a/src/service/mod.rs b/src/service/mod.rs index c5c1bd9..0296234 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,2 +1,2 @@ -pub mod openrouter; +pub mod provider; pub mod oauth; diff --git a/src/service/openrouter.rs b/src/service/openrouter.rs deleted file mode 100644 index 45b361a..0000000 --- a/src/service/openrouter.rs +++ /dev/null @@ -1,66 +0,0 @@ -use anyhow::Result; - -use crate::dto::chat::message::ChatMessage; -use crate::dto::openrouter::request::ToolDef; - -pub struct OpenRouterClient { - pub client: reqwest::blocking::Client, - pub api_key: String, - pub base_url: String, - pub model: String, -} - -impl OpenRouterClient { - pub fn new(api_key: String, model: String) -> Self { - OpenRouterClient { - client: reqwest::blocking::Client::new(), - api_key, - base_url: "https://openrouter.ai/api/v1".to_string(), - model, - } - } - - pub fn chat(&self, messages: &[ChatMessage]) -> Result { - let response = self.chat_with_tools(messages, None)?; - Ok(response.content.unwrap_or_default()) - } - - pub fn chat_with_tools( - &self, - messages: &[ChatMessage], - tools: Option>, - ) -> Result { - let req = crate::dto::openrouter::request::ChatRequest { - model: self.model.clone(), - messages: messages.to_vec(), - max_tokens: Some(4096), - temperature: Some(0.7), - tools, - stream: Some(false), - top_p: None, - stop: None, - }; - - let resp = self.client - .post(format!("{}/chat/completions", self.base_url)) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&req) - .send()?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().unwrap_or_default(); - anyhow::bail!("OpenRouter API error {}: {}", status, body); - } - - let data: crate::dto::openrouter::response::ChatResponse = resp.json()?; - let message = data - .choices - .into_iter() - .next() - .map(|c| c.message) - .ok_or_else(|| anyhow::anyhow!("OpenRouter response had no choices"))?; - Ok(message) - } -} diff --git a/src/service/provider.rs b/src/service/provider.rs new file mode 100644 index 0000000..bb80536 --- /dev/null +++ b/src/service/provider.rs @@ -0,0 +1,94 @@ +use std::time::Duration; +use anyhow::Result; + +use crate::dto::chat::message::ChatMessage; +use crate::dto::provider::request::ToolDef; + +const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; +const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +pub struct LlmClient { + pub client: reqwest::blocking::Client, + pub api_key: String, + pub base_url: String, + pub model: String, +} + +impl LlmClient { + pub fn new(api_key: String, model: String) -> Self { + let model = if model.is_empty() { + DEFAULT_MODEL.to_string() + } else { + model + }; + let client = reqwest::blocking::Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()); + LlmClient { + client, + api_key, + base_url: DEFAULT_BASE_URL.to_string(), + model, + } + } + + pub fn chat(&self, messages: &[ChatMessage]) -> Result { + let response = self.chat_with_tools(messages, None)?; + Ok(response.content.unwrap_or_default()) + } + + pub fn chat_with_tools( + &self, + messages: &[ChatMessage], + tools: Option>, + ) -> Result { + let req = crate::dto::provider::request::ChatRequest { + model: self.model.clone(), + messages: messages.to_vec(), + max_tokens: Some(4096), + temperature: Some(0.7), + tools, + stream: Some(false), + top_p: None, + stop: None, + }; + + let url = format!("{}/chat/completions", self.base_url); + let mut http_req = self.client + .post(&url) + .header("Content-Type", "application/json"); + + if !self.api_key.is_empty() { + http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key)); + } + + let resp = http_req.json(&req).send().map_err(|e| { + if e.is_timeout() { + anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT) + } else if e.is_connect() { + anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url) + } else { + anyhow::anyhow!("API request failed: {}", e) + } + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + anyhow::bail!("API error {} from {}: {}", status, self.base_url, body); + } + + let data: crate::dto::provider::response::ChatResponse = resp.json()?; + let message = data + .choices + .into_iter() + .next() + .map(|c| c.message) + .ok_or_else(|| anyhow::anyhow!("API response had no choices"))?; + Ok(message) + } +} diff --git a/src/tool/fs/helpers.rs b/src/tool/fs/helpers.rs index d386da8..4526770 100644 --- a/src/tool/fs/helpers.rs +++ b/src/tool/fs/helpers.rs @@ -25,3 +25,39 @@ pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> St format!("path '{}' does not exist (resolved to {})", rel, canon.display()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_arg_str_found() { + let args = json!({"key": "value"}); + assert_eq!(arg_str(&args, "key").unwrap(), "value"); + } + + #[test] + fn test_arg_str_missing() { + let args = json!({"other": "value"}); + assert!(arg_str(&args, "key").is_err()); + } + + #[test] + fn test_arg_str_empty_string() { + let args = json!({"key": ""}); + assert_eq!(arg_str(&args, "key").unwrap(), ""); + } + + #[test] + fn test_arg_str_wrong_type() { + let args = json!({"key": 42}); + assert!(arg_str(&args, "key").is_err()); + } + + #[test] + fn test_arg_str_null() { + let args = json!({"key": null}); + assert!(arg_str(&args, "key").is_err()); + } +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs index f728c00..47d6cae 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -148,12 +148,12 @@ pub fn tool_is_risky(name: &str) -> bool { matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") } -pub fn tool_defs(tools: &[Box]) -> Vec { +pub fn tool_defs(tools: &[Box]) -> Vec { tools .iter() - .map(|t| crate::dto::openrouter::request::ToolDef { + .map(|t| crate::dto::provider::request::ToolDef { type_: "function".to_string(), - function: crate::dto::openrouter::request::ToolFunctionDef { + function: crate::dto::provider::request::ToolFunctionDef { name: t.name().to_string(), description: t.description().to_string(), parameters: t.parameters(), diff --git a/src/view/chat.rs b/src/view/chat.rs index 16ae820..708219d 100644 --- a/src/view/chat.rs +++ b/src/view/chat.rs @@ -98,6 +98,17 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: display_lines.push(Line::from(Span::raw(""))); } + if state.turn_in_flight() { + let last_is_user = messages.last().map(|m| matches!(m.role, crate::dto::chat::message::Role::User)).unwrap_or(false); + if last_is_user { + display_lines.push(Line::from(vec![ + Span::styled(" AI ", Style::default().fg(Theme::BG).bg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD)), + Span::styled(" Thinking...", Style::default().fg(Theme::DIM)), + ])); + display_lines.push(Line::from(Span::raw(""))); + } + } + if messages.len() > scroll_offset + max_visible { let below = messages.len().saturating_sub(scroll_offset + max_visible); display_lines.push(Line::from(Span::styled( diff --git a/src/view/mod.rs b/src/view/mod.rs index df13e05..94f4140 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -201,7 +201,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ Style::default().fg(Theme::DIM), )), Line::from(Span::styled( - " OpenRouter - Multi-model API gateway", + " Zen API - opencode.ai free model", Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( diff --git a/src/view/status.rs b/src/view/status.rs index a03cdce..4c5ca5a 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -30,8 +30,13 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: .add_modifier(Modifier::BOLD), ); + let agent_status = if state.turn_in_flight() { + "PROCESSING" + } else { + "READY" + }; let center_text = Span::styled( - format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator), + format!(" | STATUS: {} | AGENT: {} ", mode_indicator, agent_status), Style::default().fg(mode_color), );