mermaid_cli/providers/tool/computer_use/mod.rs
1//! Computer-use tools — screenshot capture, mouse + keyboard control.
2//!
3//! Seven tools total (`screenshot`, `click`, `type_text`, `press_key`,
4//! `scroll`, `mouse_move`, `list_windows`) share one `ComputerUseDriver`.
5//! The driver owns the platform-specific subprocess dispatch (scrot/
6//! xdotool on X11, grim/ydotool on Wayland, screencapture/cliclick on
7//! macOS) and the `ScreenshotRegistry` — a small LRU buffer of recent
8//! capture metadata so the model can pass `screenshot_id` on
9//! `click`/`mouse_move` to lock coordinates to a specific capture.
10//!
11//! Registration is gated two ways:
12//! - `TuiMode::Headless` (`mermaid run <prompt>`) never registers any
13//! computer-use tool regardless of what the display probes say —
14//! a CI job has no user to watch a screenshot.
15//! - `Backend::probe()` runs an eager capability check at startup
16//! (env vars + required binaries + `xdpyinfo` smoke test). If the
17//! result is `Unsupported`, no tools register.
18//!
19//! The driver ALSO exposes `ensure_alive()` which every tool calls at
20//! the top of `execute`. It's a cheap re-probe that catches the
21//! "`DISPLAY=:0` ghost" case: env looks right, binaries exist, but
22//! the X server is actually unreachable (SSH forwarding without an
23//! X server, detached display, laptop lid closed).
24
25pub mod click;
26pub mod driver;
27pub mod list_windows;
28pub mod mouse_move;
29pub mod press_key;
30pub mod screenshot;
31pub mod scroll;
32pub mod type_text;
33
34use std::process::Command;
35
36use serde_json::Value;
37
38use crate::domain::{ToolMetadata, ToolOutcome, ToolRunMetadata};
39use crate::providers::ctx::{ExecContext, ProgressEvent};
40
41pub use click::ClickTool;
42pub use driver::ComputerUseDriver;
43pub use list_windows::ListWindowsTool;
44pub use mouse_move::MouseMoveTool;
45pub use press_key::PressKeyTool;
46pub use screenshot::ScreenshotTool;
47pub use scroll::ScrollTool;
48pub use type_text::TypeTextTool;
49
50/// Platform / display-server the driver dispatches to.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Backend {
53 X11,
54 Wayland,
55 MacOS,
56 Windows,
57 Unsupported,
58}
59
60impl Backend {
61 /// Whether the driver has any tools it can run on this backend.
62 pub fn is_usable(self) -> bool {
63 !matches!(self, Backend::Unsupported)
64 }
65
66 /// Whether this backend can inject pointer + keyboard events (click,
67 /// type_text, press_key, scroll, mouse_move). X11 (xdotool) and Wayland
68 /// (ydotool/wtype) only; macOS capture works via `screencapture`, but the
69 /// input verbs are unimplemented and `bail!` in the driver, so they must
70 /// not be advertised there (#35). Windows is a stub.
71 pub fn supports_input_injection(self) -> bool {
72 matches!(self, Backend::X11 | Backend::Wayland)
73 }
74
75 /// Whether this backend can enumerate windows (`list_windows`). X11 only —
76 /// via `xdotool search`; Wayland has no portable primitive and the driver
77 /// `bail!`s (#35).
78 pub fn supports_window_listing(self) -> bool {
79 matches!(self, Backend::X11)
80 }
81}
82
83/// Eager probe. Runs at startup to decide registration — does the
84/// right binary exist? Is the display reachable? Returns
85/// `Backend::Unsupported` when mermaid can't drive the display even
86/// though env vars might suggest otherwise (e.g. SSH forwarding).
87pub fn probe() -> Backend {
88 if cfg!(target_os = "macos") {
89 if has_command("screencapture") {
90 return Backend::MacOS;
91 }
92 return Backend::Unsupported;
93 }
94 if cfg!(target_os = "windows") {
95 // Windows backend is a v0.6 stub — not wired here. Once a
96 // real impl lands, probe PowerShell / SendInput here.
97 return Backend::Unsupported;
98 }
99
100 // Linux: try Wayland first (prefer if both are set).
101 if std::env::var("WAYLAND_DISPLAY").is_ok()
102 && has_command("grim")
103 && (has_command("ydotool") || has_command("wtype"))
104 {
105 return Backend::Wayland;
106 }
107
108 // Linux: fall back to X11. The xdpyinfo probe catches the ghost
109 // case — DISPLAY is set but no X server responds (common over
110 // SSH without X forwarding, or after a stale SSH reconnect).
111 if std::env::var("DISPLAY").is_ok()
112 && has_command("scrot")
113 && has_command("xdotool")
114 && xdpyinfo_alive()
115 {
116 return Backend::X11;
117 }
118
119 Backend::Unsupported
120}
121
122/// Quick re-probe used by `ComputerUseDriver::ensure_alive`. Cheaper
123/// than the full `probe()` — just checks the display answers — so
124/// every tool call can afford it.
125pub fn display_is_reachable(backend: Backend) -> bool {
126 match backend {
127 Backend::X11 => xdpyinfo_alive(),
128 Backend::Wayland => std::env::var("WAYLAND_DISPLAY").is_ok(),
129 Backend::MacOS | Backend::Windows => true,
130 Backend::Unsupported => false,
131 }
132}
133
134pub(super) fn has_command(name: &str) -> bool {
135 // `which` returns 0 iff the binary is on PATH. Cheap and universal
136 // across Linux + macOS; Windows would want `where.exe` but
137 // computer-use on Windows is stubbed out anyway.
138 Command::new("which")
139 .arg(name)
140 .output()
141 .map(|o| o.status.success() && !o.stdout.is_empty())
142 .unwrap_or(false)
143}
144
145/// Exit-0 check on `xdpyinfo` with a 200ms timeout. This is the
146/// difference between "`DISPLAY` is set" and "an X server will
147/// actually answer us."
148fn xdpyinfo_alive() -> bool {
149 if !has_command("xdpyinfo") {
150 // Some minimal X setups don't ship xdpyinfo. Fall back to a
151 // `xdotool getactivewindow` probe (we already require
152 // xdotool for clicks anyway).
153 return Command::new("xdotool")
154 .arg("getactivewindow")
155 .output()
156 .map(|o| o.status.success())
157 .unwrap_or(false);
158 }
159 // Use a timeout wrapper so a wedged display doesn't hang startup.
160 match Command::new("timeout").arg("0.2").arg("xdpyinfo").output() {
161 Ok(o) => o.status.success(),
162 Err(_) => {
163 // `timeout` not available (macOS older versions). Fall
164 // back to a direct call — shouldn't happen on Linux X11.
165 Command::new("xdpyinfo")
166 .output()
167 .map(|o| o.status.success())
168 .unwrap_or(false)
169 },
170 }
171}
172
173pub(super) fn computer_use_success(
174 action: &'static str,
175 params: Value,
176 output: String,
177 duration_secs: f64,
178) -> ToolOutcome {
179 ToolOutcome::success(output, format!("{} completed", action), duration_secs).with_metadata(
180 ToolRunMetadata {
181 detail: ToolMetadata::ComputerUse {
182 action: action.to_string(),
183 params,
184 },
185 ..ToolRunMetadata::default()
186 },
187 )
188}
189
190/// Shared post-action auto-screenshot for click / type_text / press_key.
191///
192/// When `computer_use.auto_screenshot` is enabled, captures the focused window,
193/// emits an inline `Artifact` preview on the progress channel, and returns
194/// `(summary, base64_png)` for the caller to fold into its outcome. Returns
195/// `None` when the flag is off OR the best-effort capture failed — callers then
196/// build a screenshot-less outcome (#98). Gating lives here so the three tools
197/// share one decision point rather than three byte-identical blocks.
198pub(super) async fn emit_auto_screenshot(
199 driver: &ComputerUseDriver,
200 ctx: &ExecContext,
201 caption: &'static str,
202) -> Option<(String, String)> {
203 if !ctx.config.computer_use.auto_screenshot {
204 return None;
205 }
206 let (summary, base64_png) = driver.capture_focused_for_autoshot(&ctx.token).await?;
207 if let Ok(bytes) =
208 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &base64_png)
209 {
210 let _ = ctx
211 .progress
212 .send(ProgressEvent::Artifact {
213 mime: "image/png".to_string(),
214 data: bytes,
215 caption: Some(caption.to_string()),
216 })
217 .await;
218 }
219 Some((summary, base64_png))
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn backend_unsupported_is_not_usable() {
228 assert!(!Backend::Unsupported.is_usable());
229 assert!(Backend::X11.is_usable());
230 assert!(Backend::Wayland.is_usable());
231 assert!(Backend::MacOS.is_usable());
232 }
233
234 #[test]
235 fn input_injection_only_on_linux_backends() {
236 assert!(Backend::X11.supports_input_injection());
237 assert!(Backend::Wayland.supports_input_injection());
238 assert!(!Backend::MacOS.supports_input_injection());
239 assert!(!Backend::Windows.supports_input_injection());
240 assert!(!Backend::Unsupported.supports_input_injection());
241 }
242
243 #[test]
244 fn window_listing_only_on_x11() {
245 assert!(Backend::X11.supports_window_listing());
246 assert!(!Backend::Wayland.supports_window_listing());
247 assert!(!Backend::MacOS.supports_window_listing());
248 }
249
250 #[test]
251 fn probe_does_not_panic_on_headless() {
252 // In the test runner (no DISPLAY, no WAYLAND_DISPLAY on most
253 // CI envs), probe() must return Unsupported without panicking.
254 // We don't assert a specific result because dev machines may
255 // have a live display.
256 let _ = probe();
257 }
258
259 #[tokio::test]
260 async fn auto_screenshot_is_noop_when_disabled() {
261 use crate::domain::{ToolCallId, TurnId};
262 let mut cfg = crate::app::Config::default();
263 cfg.computer_use.auto_screenshot = false;
264 let (tx, mut rx) = tokio::sync::mpsc::channel::<ProgressEvent>(8);
265 let ctx = ExecContext::new(
266 tokio_util::sync::CancellationToken::new(),
267 tx,
268 ToolCallId(1),
269 TurnId(1),
270 std::path::PathBuf::from("/tmp"),
271 std::sync::Arc::new(cfg),
272 String::new(),
273 None,
274 None,
275 None,
276 crate::runtime::SafetyMode::FullAccess,
277 None,
278 None,
279 None,
280 None,
281 None,
282 );
283 // Backend is irrelevant — the flag short-circuits before any capture.
284 let driver = ComputerUseDriver::new(Backend::Unsupported);
285 assert!(emit_auto_screenshot(&driver, &ctx, "test").await.is_none());
286 assert!(rx.try_recv().is_err(), "no artifact emitted when disabled");
287 }
288}