mermaid-cli 0.3.10

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::utils::MutexExt;

use crate::{
    agents::{execute_action, ActionResult as AgentActionResult, AgentAction},
    app::Config,
    cli::OutputFormat,
    models::{ChatMessage, MessageRole, Model, ModelConfig, ModelFactory},
};

/// Result of a non-interactive run
#[derive(Debug, Serialize, Deserialize)]
pub struct NonInteractiveResult {
    /// The prompt that was executed
    pub prompt: String,
    /// The model's response
    pub response: String,
    /// Actions that were executed (if any)
    pub actions: Vec<ActionResult>,
    /// Any errors that occurred
    pub errors: Vec<String>,
    /// Metadata about the execution
    pub metadata: ExecutionMetadata,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ActionResult {
    /// Type of action (file_write, command, etc.)
    pub action_type: String,
    /// Target (file path or command)
    pub target: String,
    /// Whether the action was executed successfully
    pub success: bool,
    /// Output or error message
    pub output: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ExecutionMetadata {
    /// Model used
    pub model: String,
    /// Total tokens used
    pub tokens_used: Option<usize>,
    /// Execution time in milliseconds
    pub duration_ms: u128,
    /// Whether actions were executed
    pub actions_executed: bool,
}

/// Non-interactive runner for executing single prompts
pub struct NonInteractiveRunner {
    model: Arc<RwLock<Box<dyn Model>>>,
    no_execute: bool,
    max_tokens: Option<usize>,
}

impl NonInteractiveRunner {
    /// Create a new non-interactive runner
    pub async fn new(
        model_id: String,
        _project_path: PathBuf,  // Unused - LLM explores via tools
        config: Config,
        no_execute: bool,
        max_tokens: Option<usize>,
        backend: Option<&str>,
    ) -> Result<Self> {
        // Create model instance with optional backend preference
        let model = ModelFactory::create_with_backend(&model_id, Some(&config), backend).await?;

        Ok(Self {
            model: Arc::new(RwLock::new(model)),
            no_execute,
            max_tokens,
        })
    }

    /// Execute a single prompt and return the result
    pub async fn execute(&self, prompt: String) -> Result<NonInteractiveResult> {
        let start_time = std::time::Instant::now();
        let mut errors = Vec::new();
        let mut actions = Vec::new();

        // Build messages - LLM explores codebase via tools, no context injection
        let system_content = "You are an AI coding assistant. Use tools to explore and modify the codebase as needed."
            .to_string();

        let system_message = ChatMessage {
            role: MessageRole::System,
            content: system_content,
            timestamp: chrono::Local::now(),
            actions: Vec::new(),
            thinking: None,
            images: None,
            tool_calls: None,
            tool_call_id: None,
            tool_name: None,
        };

        let user_message = ChatMessage {
            role: MessageRole::User,
            content: prompt.clone(),
            timestamp: chrono::Local::now(),
            actions: Vec::new(),
            thinking: None,
            images: None,
            tool_calls: None,
            tool_call_id: None,
            tool_name: None,
        };

        let messages = vec![system_message, user_message];

        // Get model name from the model
        let model_guard = self.model.read().await;
        let model_name = model_guard.name().to_string();
        drop(model_guard);

        // Create model config
        let model_config = ModelConfig {
            model: model_name,
            temperature: 0.7,
            max_tokens: self.max_tokens.unwrap_or(4096),
            top_p: Some(1.0),
            frequency_penalty: None,
            presence_penalty: None,
            system_prompt: None,
            thinking_enabled: false, // Non-interactive mode doesn't need thinking
            backend_options: std::collections::HashMap::new(),
        };

        // Send prompt to model
        let full_response;
        let tokens_used;

        // Create a callback to capture the response
        let response_text = Arc::new(std::sync::Mutex::new(String::new()));
        let response_clone = Arc::clone(&response_text);
        let callback = Arc::new(move |chunk: &str| {
            let mut resp = response_clone.lock_mut_safe();
            resp.push_str(chunk);
        });

        // Call the model
        let model_name;
        let result = {
            let model = self.model.write().await;
            model_name = model.name().to_string();
            model
                .chat(&messages, &model_config, Some(callback))
                .await
        };

        // Parse actions from tool calls (Ollama native function calling)
        let parsed_actions: Vec<AgentAction> = match result {
            Ok(response) => {
                // Try to get content from the callback first
                let callback_content = response_text.lock_mut_safe().clone();
                if !callback_content.is_empty() {
                    full_response = callback_content;
                } else {
                    full_response = response.content;
                }
                tokens_used = response.usage.map(|u| u.total_tokens).unwrap_or(0);

                // Convert tool_calls to AgentActions
                if let Some(tool_calls) = response.tool_calls {
                    tool_calls
                        .iter()
                        .filter_map(|tc| tc.to_agent_action().ok())
                        .collect()
                } else {
                    vec![]
                }
            },
            Err(e) => {
                errors.push(format!("Model error: {}", e));
                full_response = response_text.lock_mut_safe().clone();
                tokens_used = 0;
                vec![]
            },
        };

        // Execute actions if not in no-execute mode
        if !self.no_execute && !parsed_actions.is_empty() {
            for action in parsed_actions {
                let (action_type, target) = match &action {
                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
                    AgentAction::EditFile { path, .. } => ("edit_file", path.clone()),
                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
                    AgentAction::ReadFile { paths } => {
                        if paths.len() == 1 {
                            ("file_read", paths[0].clone())
                        } else {
                            ("file_read", format!("{} files", paths.len()))
                        }
                    }
                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
                    AgentAction::GitDiff { paths } => {
                        if paths.len() == 1 {
                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
                        } else {
                            ("git_diff", format!("{} paths", paths.len()))
                        }
                    }
                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
                    AgentAction::WebSearch { queries } => {
                        if queries.len() == 1 {
                            ("web_search", queries[0].0.clone())
                        } else {
                            ("web_search", format!("{} queries", queries.len()))
                        }
                    }
                    AgentAction::WebFetch { url } => ("web_fetch", url.clone()),
                };

                let result = execute_action(&action).await;

                let action_result = match result {
                    AgentActionResult::Success { output } => ActionResult {
                        action_type: action_type.to_string(),
                        target,
                        success: true,
                        output: Some(output),
                    },
                    AgentActionResult::Error { error } => ActionResult {
                        action_type: action_type.to_string(),
                        target,
                        success: false,
                        output: Some(error),
                    },
                };

                actions.push(action_result);
            }
        } else if !parsed_actions.is_empty() {
            // Actions were found but not executed (no-execute mode)
            for action in parsed_actions {
                let (action_type, target) = match &action {
                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
                    AgentAction::EditFile { path, .. } => ("edit_file", path.clone()),
                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
                    AgentAction::ReadFile { paths } => {
                        if paths.len() == 1 {
                            ("file_read", paths[0].clone())
                        } else {
                            ("file_read", format!("{} files", paths.len()))
                        }
                    }
                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
                    AgentAction::GitDiff { paths } => {
                        if paths.len() == 1 {
                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
                        } else {
                            ("git_diff", format!("{} paths", paths.len()))
                        }
                    }
                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
                    AgentAction::WebSearch { queries } => {
                        if queries.len() == 1 {
                            ("web_search", queries[0].0.clone())
                        } else {
                            ("web_search", format!("{} queries", queries.len()))
                        }
                    }
                    AgentAction::WebFetch { url } => ("web_fetch", url.clone()),
                };

                actions.push(ActionResult {
                    action_type: action_type.to_string(),
                    target,
                    success: false,
                    output: Some("Not executed (--no-execute mode)".to_string()),
                });
            }
        }

        let duration_ms = start_time.elapsed().as_millis();
        let actions_executed = !self.no_execute && !actions.is_empty();

        Ok(NonInteractiveResult {
            prompt,
            response: full_response,
            actions,
            errors,
            metadata: ExecutionMetadata {
                model: model_name,
                tokens_used: Some(tokens_used),
                duration_ms,
                actions_executed,
            },
        })
    }

    /// Format the result according to the output format
    pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
        match format {
            OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
                format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
            }),
            OutputFormat::Text => {
                let mut output = String::new();
                output.push_str(&result.response);

                if !result.actions.is_empty() {
                    output.push_str("\n\n--- Actions ---\n");
                    for action in &result.actions {
                        output.push_str(&format!(
                            "[{}] {} - {}\n",
                            if action.success { "OK" } else { "FAIL" },
                            action.action_type,
                            action.target
                        ));
                        if let Some(ref out) = action.output {
                            output.push_str(&format!("  {}\n", out));
                        }
                    }
                }

                if !result.errors.is_empty() {
                    output.push_str("\n--- Errors ---\n");
                    for error in &result.errors {
                        output.push_str(&format!("{}\n", error));
                    }
                }

                output
            },
            OutputFormat::Markdown => {
                let mut output = String::new();

                output.push_str("## Response\n\n");
                output.push_str(&result.response);
                output.push_str("\n\n");

                if !result.actions.is_empty() {
                    output.push_str("## Actions Executed\n\n");
                    for action in &result.actions {
                        let status = if action.success { "SUCCESS" } else { "FAILED" };
                        output.push_str(&format!(
                            "- {} **{}**: `{}`\n",
                            status, action.action_type, action.target
                        ));
                        if let Some(ref out) = action.output {
                            output.push_str(&format!("  ```\n  {}\n  ```\n", out));
                        }
                    }
                    output.push_str("\n");
                }

                if !result.errors.is_empty() {
                    output.push_str("## Errors\n\n");
                    for error in &result.errors {
                        output.push_str(&format!("- {}\n", error));
                    }
                    output.push_str("\n");
                }

                output.push_str("---\n");
                output.push_str(&format!(
                    "*Model: {} | Tokens: {} | Duration: {}ms*\n",
                    result.metadata.model,
                    result.metadata.tokens_used.unwrap_or(0),
                    result.metadata.duration_ms
                ));

                output
            },
        }
    }
}