Skip to main content

mermaid_cli/app/
run_non_interactive.rs

1//! Headless driver for `mermaid run <prompt>`.
2//!
3//! Same reducer + same effect runner + same providers + same tools
4//! as the interactive path. Differences: no `TerminalGuard`, no
5//! crossterm events, no tick timer, no render. One synthetic
6//! `Msg::SubmitPrompt` seeds the reducer; the loop spins until
7//! `state.turn == Idle` and the queue is empty.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::Config;
16use crate::app::lifecycle::RuntimeLifecycle;
17use crate::cli::OutputFormat;
18use crate::domain::{Msg, State, TurnState, update};
19use crate::effect::EffectRunner;
20use crate::models::MessageRole;
21use crate::providers::ToolRegistry;
22
23/// Output shape the CLI prints.
24#[derive(Debug, Default)]
25pub struct RunResult {
26    pub response: String,
27    pub reasoning: Option<String>,
28    pub total_tokens: usize,
29    pub errors: Vec<String>,
30}
31
32/// Per-invocation options for `run_non_interactive`.
33///
34/// Added as a struct so new flags can land without reshuffling the
35/// function's positional args. All fields default to "no change".
36#[derive(Debug, Default, Clone)]
37pub struct RunOptions {
38    /// When true, register an empty `ToolRegistry` — the model sees no
39    /// tools and can't take actions. Dry-run mode for
40    /// `mermaid run --no-execute`.
41    pub no_execute: bool,
42}
43
44/// Drive one prompt to completion. Bounded by a generous 20-minute
45/// wall-clock so a runaway model doesn't hang a script.
46pub async fn run_non_interactive(
47    config: Config,
48    cwd: PathBuf,
49    model_id: String,
50    prompt: String,
51) -> Result<RunResult> {
52    run_non_interactive_with(config, cwd, model_id, prompt, RunOptions::default()).await
53}
54
55/// Same as `run_non_interactive` but with explicit per-call options.
56/// Kept separate so existing call sites keep compiling unchanged.
57pub async fn run_non_interactive_with(
58    config: Config,
59    cwd: PathBuf,
60    model_id: String,
61    prompt: String,
62    opts: RunOptions,
63) -> Result<RunResult> {
64    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
65    // F6 `--no-execute`: build an empty tool registry so the model can
66    // plan but never act. MCP init below is also skipped to match.
67    let tools = if opts.no_execute {
68        std::sync::Arc::new(ToolRegistry::new())
69    } else {
70        ToolRegistry::build(
71            &config,
72            crate::providers::TuiMode::Headless,
73            providers.clone(),
74        )
75    };
76    let (mut runner, mut msg_rx) = EffectRunner::pair_from(cwd.clone(), providers, tools);
77
78    let mut state = State::new(config.clone(), cwd, model_id);
79    let mut lifecycle = RuntimeLifecycle::new();
80
81    // Bootstrap effects (MCP init) before the first prompt. The
82    // instructions refresh used to dispatch here too, but F8 moved it
83    // inline into `handle_submit_prompt` (synchronous stat + optional
84    // small read) so the very first call actually sees the current
85    // MERMAID.md — previously the dispatch race meant run #1 missed
86    // edits and only run #2 picked them up.
87    //
88    // Skip MCP init when `--no-execute` — MCP tools would advertise
89    // through the registry we just emptied, so spinning up their
90    // processes is wasted work.
91    if !config.mcp_servers.is_empty() && !opts.no_execute {
92        runner.dispatch(crate::domain::Cmd::InitMcpServers(
93            config.mcp_servers.clone(),
94        ));
95    }
96
97    // Seed the turn.
98    let seed = Msg::SubmitPrompt {
99        text: prompt,
100        attachment_ids: vec![],
101    };
102    let (new_state, cmds) = update(state, seed);
103    state = new_state;
104    for cmd in cmds {
105        runner.dispatch(cmd);
106    }
107
108    let deadline = Duration::from_secs(20 * 60);
109
110    let drive = async {
111        while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
112            let msg = tokio::select! {
113                m = msg_rx.recv() => match m {
114                    Some(m) => m,
115                    None => break,
116                },
117                s = lifecycle.next_msg() => match s {
118                    Some(s) => s,
119                    None => continue,
120                },
121            };
122            let (new_state, cmds) = update(state, msg);
123            state = new_state;
124            for cmd in cmds {
125                runner.dispatch(cmd);
126            }
127            if state.should_exit {
128                break;
129            }
130        }
131        state
132    };
133
134    let final_state = timeout(deadline, drive).await.map_err(|_| {
135        anyhow::anyhow!(
136            "non-interactive run exceeded {} seconds",
137            deadline.as_secs()
138        )
139    })?;
140
141    runner.shutdown().await;
142    Ok(build_result(&final_state))
143}
144
145/// Walk the committed message history and pull out the last
146/// assistant response + any errors encountered.
147fn build_result(state: &State) -> RunResult {
148    let mut out = RunResult {
149        total_tokens: state.session.cumulative_token_usage.total_tokens,
150        ..RunResult::default()
151    };
152
153    for msg in state.session.messages() {
154        for action in &msg.actions {
155            if let crate::domain::ActionResult::Error { error } = &action.result {
156                out.errors
157                    .push(format!("{}: {}", action.action_type, error));
158            }
159        }
160    }
161
162    if let Some(last) = state
163        .session
164        .messages()
165        .iter()
166        .rev()
167        .find(|m| m.role == MessageRole::Assistant)
168    {
169        out.response = last.content.clone();
170        out.reasoning = last.thinking.clone();
171    }
172
173    out
174}
175
176/// Render a `RunResult` in the requested output format.
177pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
178    match format {
179        OutputFormat::Text => {
180            if result.response.is_empty() && !result.errors.is_empty() {
181                result.errors.join("\n")
182            } else {
183                result.response.clone()
184            }
185        },
186        OutputFormat::Markdown => {
187            let mut out = result.response.clone();
188            if !result.errors.is_empty() {
189                out.push_str("\n\n---\n\n## Errors\n\n");
190                for e in &result.errors {
191                    out.push_str(&format!("- {}\n", e));
192                }
193            }
194            out
195        },
196        OutputFormat::Json => {
197            let json = serde_json::json!({
198                "response": result.response,
199                "reasoning": result.reasoning,
200                "total_tokens": result.total_tokens,
201                "errors": result.errors,
202            });
203            serde_json::to_string_pretty(&json).unwrap_or_default()
204        },
205    }
206}