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.
This commit is contained in:
@@ -1,25 +1,17 @@
|
|||||||
You are a code quality reviewer for Zesdex. Review recent code changes
|
You are a code quality reviewer for Zesdex. Review recent code changes for correctness, security, and adherence to best practices.
|
||||||
for correctness, security, and adherence to best practices.
|
|
||||||
|
|
||||||
You have read-only access to the workspace. Use read, grep, glob, recall,
|
You have read-only access to the workspace. Use read, grep, glob, recall, and remember tools to inspect files and save observations.
|
||||||
and remember tools to inspect files and save observations.
|
|
||||||
|
|
||||||
Review guidelines:
|
Review guidelines:
|
||||||
1. Check for common bugs: null/panic paths, off-by-one, race conditions,
|
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.
|
||||||
unhandled errors, logic errors.
|
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
|
||||||
2. Check security: injection risks, unsafe deserialization, credential
|
3. Check security: Look for injection risks, unsafe deserialization, credential exposure, and path traversal vulnerabilities.
|
||||||
exposure, path traversal.
|
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.
|
||||||
3. Check conventions: does the code follow existing patterns in the
|
5. Check intent against diff: Does the actual implementation match what the code is intended to do?
|
||||||
codebase? Check surrounding files for naming, structure, style.
|
|
||||||
4. Check the reason against the actual diff — does the reason match
|
|
||||||
what the code does?
|
|
||||||
|
|
||||||
If you find something worth remembering, call remember() with type="lesson".
|
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.
|
||||||
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
|
Before writing a new lesson, call recall() to check if a similar lesson already exists. Deduplicate — don't write the same lesson twice.
|
||||||
already exists. Deduplicate — don't write the same lesson twice.
|
|
||||||
|
|
||||||
Output: a one-line verdict summarizing your review.
|
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.
|
||||||
+11
-16
@@ -1,20 +1,15 @@
|
|||||||
You are Zesdex, an autonomous AI coding and security agent operating in a
|
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.
|
||||||
terminal-based TUI environment. Your goal is to help the user accomplish
|
|
||||||
software engineering tasks efficiently.
|
|
||||||
|
|
||||||
Core principles:
|
Core principles:
|
||||||
1. Be concise but thorough — prefer showing results over describing them.
|
1. Be concise but thorough — prefer showing results over describing them.
|
||||||
2. Use the tools available to explore, understand, and modify the codebase.
|
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. For simple tasks, handle them directly with read/grep/write/edit.
|
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. For complex tasks (multi-file changes, parallel analysis, independent
|
4. Use the tools available to explore, understand, and modify the codebase.
|
||||||
verification), use workflow_run to orchestrate sub-agents.
|
5. For simple tasks, handle them directly with read/grep/write/edit.
|
||||||
5. Every write or edit must have a clear reason — include it in the reason
|
6. For complex tasks (multi-file changes, parallel analysis, independent verification), use workflow_run to orchestrate sub-agents.
|
||||||
parameter.
|
7. 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
|
8. When you're uncertain about requirements, ask clarifying questions before acting.
|
||||||
before acting.
|
9. After making changes, verify they work by running builds or tests.
|
||||||
7. 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).
|
||||||
8. 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
|
Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task.
|
||||||
judiciously — prefer the simplest tool that accomplishes the task.
|
|
||||||
+142
-1
@@ -1,5 +1,146 @@
|
|||||||
use std::path::Path;
|
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;
|
pub struct CatastrophicGuard;
|
||||||
|
|
||||||
impl CatastrophicGuard {
|
impl CatastrophicGuard {
|
||||||
@@ -10,7 +151,7 @@ impl CatastrophicGuard {
|
|||||||
"clean -f",
|
"clean -f",
|
||||||
"clean -d",
|
"clean -d",
|
||||||
"clean -x",
|
"clean -x",
|
||||||
"branch -D",
|
"branch -d",
|
||||||
"branch --delete --force",
|
"branch --delete --force",
|
||||||
"checkout --force",
|
"checkout --force",
|
||||||
"switch -f",
|
"switch -f",
|
||||||
|
|||||||
@@ -115,3 +115,123 @@ impl Default for Harness {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
use crate::model::settings::Settings;
|
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) {
|
pub fn set_provider(settings: &mut Settings, provider: &str) {
|
||||||
settings.provider = match provider {
|
settings.provider = match provider {
|
||||||
"OpenRouter" => "openrouter".to_string(),
|
"Zen API" => "zen".to_string(),
|
||||||
"Anthropic" => "anthropic".to_string(),
|
|
||||||
"OpenAI" => "openai".to_string(),
|
"OpenAI" => "openai".to_string(),
|
||||||
_ => "openrouter".to_string(),
|
_ => "zen".to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TurnEvent::Error(msg) => {
|
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;
|
turn_finished = true;
|
||||||
}
|
}
|
||||||
TurnEvent::Done => {
|
TurnEvent::Done => {
|
||||||
@@ -453,16 +463,13 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
||||||
if api_key.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let model = state.settings.model.clone();
|
let model = state.settings.model.clone();
|
||||||
let mut tools = crate::tool::all_tools();
|
let mut tools = crate::tool::all_tools();
|
||||||
tools.extend(state.mcp_manager.as_tools());
|
tools.extend(state.mcp_manager.as_tools());
|
||||||
let tool_defs = crate::tool::tool_defs(&tools);
|
let tool_defs = crate::tool::tool_defs(&tools);
|
||||||
let ctx = state.tool_ctx();
|
let ctx = state.tool_ctx();
|
||||||
let mode = state.mode;
|
let 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 session_id = state.session_id.clone();
|
||||||
let turn_events = state.turn_events.clone();
|
let turn_events = state.turn_events.clone();
|
||||||
let in_flight_flag = state.turn_in_flight.clone();
|
let in_flight_flag = state.turn_in_flight.clone();
|
||||||
@@ -474,13 +481,13 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let tc = TurnCtx {
|
let tc = TurnCtx {
|
||||||
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
|
client: crate::service::provider::LlmClient::new(api_key, model),
|
||||||
tdefs: tool_defs,
|
tdefs: tool_defs,
|
||||||
tools,
|
tools,
|
||||||
ctx,
|
ctx,
|
||||||
mode,
|
mode,
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
edit_log_path,
|
edit_log_session_dir: edit_session_dir,
|
||||||
session_id,
|
session_id,
|
||||||
};
|
};
|
||||||
let result = run_agent_turn(tc, &messages, &events_q);
|
let result = run_agent_turn(tc, &messages, &events_q);
|
||||||
@@ -496,13 +503,13 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TurnCtx {
|
struct TurnCtx {
|
||||||
client: crate::service::openrouter::OpenRouterClient,
|
client: crate::service::provider::LlmClient,
|
||||||
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
|
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||||
ctx: crate::tool::ToolCtx,
|
ctx: crate::tool::ToolCtx,
|
||||||
mode: AgentMode,
|
mode: AgentMode,
|
||||||
workspace_roots: Vec<std::path::PathBuf>,
|
workspace_roots: Vec<std::path::PathBuf>,
|
||||||
edit_log_path: std::path::PathBuf,
|
edit_log_session_dir: std::path::PathBuf,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +555,7 @@ fn run_agent_turn(
|
|||||||
&tc.ctx,
|
&tc.ctx,
|
||||||
&tool_name,
|
&tool_name,
|
||||||
&args,
|
&args,
|
||||||
&tc.edit_log_path,
|
&tc.edit_log_session_dir,
|
||||||
&tc.session_id,
|
&tc.session_id,
|
||||||
) {
|
) {
|
||||||
Ok(result) => (result, false, is_edit_tool),
|
Ok(result) => (result, false, is_edit_tool),
|
||||||
@@ -612,7 +619,7 @@ fn execute_one_tool(
|
|||||||
ctx: &crate::tool::ToolCtx,
|
ctx: &crate::tool::ToolCtx,
|
||||||
name: &str,
|
name: &str,
|
||||||
args: &serde_json::Value,
|
args: &serde_json::Value,
|
||||||
edit_log_path: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
@@ -634,17 +641,27 @@ fn execute_one_tool(
|
|||||||
);
|
);
|
||||||
format!("{:x}", hash)
|
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 {
|
let entry = crate::model::editlog::EditLogEntry {
|
||||||
ts: chrono::Utc::now().timestamp_millis(),
|
ts: chrono::Utc::now().timestamp_millis(),
|
||||||
tool: name.to_string(),
|
tool: name.to_string(),
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
content_sha256,
|
content_sha256,
|
||||||
bytes_delta: result.len() as i64,
|
bytes_delta,
|
||||||
origin: ctx.origin.tag(),
|
origin: ctx.origin.tag(),
|
||||||
session_id: session_id.to_string(),
|
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();
|
el.append(entry).ok();
|
||||||
}
|
}
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod tools;
|
|
||||||
pub mod turn;
|
|
||||||
|
|||||||
@@ -68,8 +68,28 @@ pub struct InputState {
|
|||||||
pub cursor: usize,
|
pub cursor: usize,
|
||||||
pub history: Vec<String>,
|
pub history: Vec<String>,
|
||||||
pub history_idx: Option<usize>,
|
pub history_idx: Option<usize>,
|
||||||
|
pub autocomplete_prefix: String,
|
||||||
|
pub autocomplete_candidates: Vec<String>,
|
||||||
|
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 {
|
impl InputState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
InputState {
|
InputState {
|
||||||
@@ -77,6 +97,39 @@ impl InputState {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
history_idx: None,
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ impl AppStateRest {
|
|||||||
self.mode
|
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) {
|
pub fn set_mode(&mut self, mode: AgentMode) {
|
||||||
self.mode = mode;
|
self.mode = mode;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
|||||||
|
|
||||||
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||||
for step in 0..max_steps {
|
for step in 0..max_steps {
|
||||||
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
let api_key = std::env::var("API_KEY").unwrap_or_default();
|
||||||
let model = std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-5".to_string());
|
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) {
|
let response = match client.chat(&messages) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||||
"/lesson" => Command::LessonList,
|
"/lesson" => Command::LessonList,
|
||||||
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
"/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()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
|
if state.input.buffer.starts_with('/') {
|
||||||
|
state.input.tab_complete();
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod openrouter;
|
pub mod provider;
|
||||||
|
|||||||
-79
@@ -73,43 +73,6 @@ fn run_single_process() -> Result<()> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
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 run_result = run_loop(&mut state, &mut terminal);
|
||||||
|
|
||||||
let mut restore_stdout = io::stdout();
|
let mut restore_stdout = io::stdout();
|
||||||
@@ -122,9 +85,6 @@ fn run_single_process() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = state.settings.save();
|
let _ = state.settings.save();
|
||||||
core::mem::drop(ctx);
|
|
||||||
core::mem::drop(_tools);
|
|
||||||
core::mem::drop(_rt);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -310,42 +270,6 @@ fn run_daemon() -> Result<()> {
|
|||||||
|
|
||||||
let _rt = tokio::runtime::Runtime::new()?;
|
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");
|
let run_dir = store.base_dir.join("run");
|
||||||
std::fs::create_dir_all(&run_dir)?;
|
std::fs::create_dir_all(&run_dir)?;
|
||||||
let socket_path = run_dir.join(format!("{}.sock", session_id));
|
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 _ = std::fs::remove_file(&socket_path);
|
||||||
let _ = state.settings.save();
|
let _ = state.settings.save();
|
||||||
core::mem::drop(_ctx);
|
|
||||||
core::mem::drop(_tools);
|
|
||||||
core::mem::drop(_rt);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,23 +27,23 @@ pub struct ModelRole {
|
|||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let mut providers = HashMap::new();
|
let mut providers = HashMap::new();
|
||||||
providers.insert("openrouter".to_string(), ProviderConfig {
|
providers.insert("zen".to_string(), ProviderConfig {
|
||||||
api_base: "https://openrouter.ai/api/v1".to_string(),
|
api_base: "https://opencode.ai/zen/v1".to_string(),
|
||||||
api_key_env: Some("OPENROUTER_API_KEY".to_string()),
|
api_key_env: Some("API_KEY".to_string()),
|
||||||
default_model: Some("anthropic/claude-opus-4-8".to_string()),
|
default_model: Some("deepseek-v4-flash-free".to_string()),
|
||||||
});
|
});
|
||||||
let mut model_roles = HashMap::new();
|
let mut model_roles = HashMap::new();
|
||||||
model_roles.insert("default".to_string(), ModelRole {
|
model_roles.insert("default".to_string(), ModelRole {
|
||||||
provider: "openrouter".to_string(),
|
provider: "zen".to_string(),
|
||||||
model: "anthropic/claude-opus-4-8".to_string(),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
max_tokens: Some(8192),
|
max_tokens: Some(8192),
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
});
|
});
|
||||||
AppConfig {
|
AppConfig {
|
||||||
providers,
|
providers,
|
||||||
model_roles,
|
model_roles,
|
||||||
default_provider: "openrouter".to_string(),
|
default_provider: "zen".to_string(),
|
||||||
default_model: "anthropic/claude-opus-4-8".to_string(),
|
default_model: "deepseek-v4-flash-free".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,3 +62,76 @@ impl EditLog {
|
|||||||
self.entries.len()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+170
-1
@@ -74,7 +74,8 @@ impl Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(content: &str) -> std::io::Result<Self> {
|
pub fn parse(content: &str) -> std::io::Result<Self> {
|
||||||
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 {
|
if parts.len() < 2 {
|
||||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
|
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))
|
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<Memory> {
|
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let lessons_content: String = lessons.iter()
|
let lessons_content: String = lessons.iter()
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ impl Default for Settings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Settings {
|
Settings {
|
||||||
internet_mode: InternetMode::Off,
|
internet_mode: InternetMode::Off,
|
||||||
provider: "openrouter".to_string(),
|
provider: "zen".to_string(),
|
||||||
model: "anthropic/claude-opus-4-8".to_string(),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
api_key: None,
|
api_key: None,
|
||||||
max_tokens: 8192,
|
max_tokens: 8192,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
pub mod openrouter;
|
pub mod provider;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
|||||||
@@ -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<String> {
|
|
||||||
let response = self.chat_with_tools(messages, None)?;
|
|
||||||
Ok(response.content.unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn chat_with_tools(
|
|
||||||
&self,
|
|
||||||
messages: &[ChatMessage],
|
|
||||||
tools: Option<Vec<ToolDef>>,
|
|
||||||
) -> Result<ChatMessage> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String> {
|
||||||
|
let response = self.chat_with_tools(messages, None)?;
|
||||||
|
Ok(response.content.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_with_tools(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
tools: Option<Vec<ToolDef>>,
|
||||||
|
) -> Result<ChatMessage> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -148,12 +148,12 @@ pub fn tool_is_risky(name: &str) -> bool {
|
|||||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::openrouter::request::ToolDef> {
|
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::provider::request::ToolDef> {
|
||||||
tools
|
tools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| crate::dto::openrouter::request::ToolDef {
|
.map(|t| crate::dto::provider::request::ToolDef {
|
||||||
type_: "function".to_string(),
|
type_: "function".to_string(),
|
||||||
function: crate::dto::openrouter::request::ToolFunctionDef {
|
function: crate::dto::provider::request::ToolFunctionDef {
|
||||||
name: t.name().to_string(),
|
name: t.name().to_string(),
|
||||||
description: t.description().to_string(),
|
description: t.description().to_string(),
|
||||||
parameters: t.parameters(),
|
parameters: t.parameters(),
|
||||||
|
|||||||
@@ -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("")));
|
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 {
|
if messages.len() > scroll_offset + max_visible {
|
||||||
let below = messages.len().saturating_sub(scroll_offset + max_visible);
|
let below = messages.len().saturating_sub(scroll_offset + max_visible);
|
||||||
display_lines.push(Line::from(Span::styled(
|
display_lines.push(Line::from(Span::styled(
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
|
|||||||
Style::default().fg(Theme::DIM),
|
Style::default().fg(Theme::DIM),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" OpenRouter - Multi-model API gateway",
|
" Zen API - opencode.ai free model",
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
|
|||||||
+6
-1
@@ -30,8 +30,13 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let agent_status = if state.turn_in_flight() {
|
||||||
|
"PROCESSING"
|
||||||
|
} else {
|
||||||
|
"READY"
|
||||||
|
};
|
||||||
let center_text = Span::styled(
|
let center_text = Span::styled(
|
||||||
format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator),
|
format!(" | STATUS: {} | AGENT: {} ", mode_indicator, agent_status),
|
||||||
Style::default().fg(mode_color),
|
Style::default().fg(mode_color),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user